1use super::{
8 NnsProposalCollectionState,
9 collection::validate_collection_state,
10 model::{NnsProposalRewardStatus, NnsProposalRow, NnsProposalStatus, NnsProposalTopic},
11};
12use crate::{
13 nns::{
14 MAINNET_GOVERNANCE_CANISTER_ID,
15 governance::{
16 NnsGovernanceCollectionStatus, NnsGovernanceSourceProvenance,
17 validate_governance_report_source, validate_governance_time_interval,
18 },
19 },
20 subnet_catalog::MAINNET_NETWORK,
21};
22use serde::{Deserialize, Serialize};
23use std::collections::{BTreeMap, HashSet};
24use thiserror::Error as ThisError;
25
26pub const NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION: u32 = 1;
28
29const SECONDS_PER_DAY: u64 = 86_400;
30
31#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
38pub struct NnsProposalActivityRequest {
39 pub from_proposal_timestamp_seconds: Option<u64>,
41 pub until_proposal_timestamp_seconds: Option<u64>,
43}
44
45#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52pub struct NnsProposalTopicCount {
53 pub topic: i32,
55 pub topic_text: NnsProposalTopic,
57 pub proposal_count: u64,
59}
60
61#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
68pub struct NnsProposalStatusCount {
69 pub status: i32,
71 pub status_text: NnsProposalStatus,
73 pub proposal_count: u64,
75}
76
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84pub struct NnsProposalRewardStatusCount {
85 pub reward_status: i32,
87 pub reward_status_text: NnsProposalRewardStatus,
89 pub proposal_count: u64,
91}
92
93#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
100pub struct NnsProposalDayCount {
101 pub day_start_timestamp_seconds: u64,
103 pub proposal_count: u64,
105}
106
107#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
114pub struct NnsProposalActivityReport {
115 pub schema_version: u32,
117 pub network: String,
119 pub governance_canister_id: String,
121 pub source: NnsGovernanceSourceProvenance,
123 pub collection_started_at: String,
125 pub collection_updated_at: String,
127 pub collection_page_count: u32,
129 pub collected_proposal_count: u64,
131 pub point_in_time_guaranteed: bool,
133 pub from_proposal_timestamp_seconds: Option<u64>,
135 pub until_proposal_timestamp_seconds: Option<u64>,
137 pub included_proposal_count: u64,
139 pub excluded_before_from_count: u64,
141 pub excluded_at_or_after_until_count: u64,
143 pub earliest_included_proposal_timestamp_seconds: Option<u64>,
145 pub latest_included_proposal_timestamp_seconds: Option<u64>,
147 pub topic_counts: Vec<NnsProposalTopicCount>,
149 pub status_counts: Vec<NnsProposalStatusCount>,
151 pub reward_status_counts: Vec<NnsProposalRewardStatusCount>,
153 pub day_counts: Vec<NnsProposalDayCount>,
155}
156
157#[derive(Debug, Eq, PartialEq, ThisError)]
164#[error("invalid NNS proposal activity report: {reason}")]
165pub struct NnsProposalActivityValidationError {
166 pub reason: String,
168}
169
170#[derive(Debug, ThisError)]
177pub enum NnsProposalActivityError {
178 #[error("invalid NNS proposal collection state for activity projection: {reason}")]
180 InvalidCollectionState {
181 reason: String,
183 },
184
185 #[error("NNS proposal activity requires a complete collection; state is {status}")]
187 CollectionNotComplete {
188 status: NnsGovernanceCollectionStatus,
190 },
191
192 #[error(
194 "invalid NNS proposal activity time window: from {from_proposal_timestamp_seconds} must be below until {until_proposal_timestamp_seconds}"
195 )]
196 InvalidTimeWindow {
197 from_proposal_timestamp_seconds: u64,
199 until_proposal_timestamp_seconds: u64,
201 },
202
203 #[error(
205 "NNS proposal activity received {actual} rows; complete collection accounts for {expected}"
206 )]
207 ProposalCountMismatch {
208 expected: u64,
210 actual: u64,
212 },
213
214 #[error("NNS proposal activity received a row without a proposal id")]
216 MissingProposalId,
217
218 #[error("NNS proposal activity received proposal id zero")]
220 ZeroProposalId,
221
222 #[error("NNS proposal activity received duplicate proposal id {proposal_id}")]
224 DuplicateProposalId {
225 proposal_id: u64,
227 },
228
229 #[error("NNS proposal {proposal_id} has proposal timestamp zero")]
231 ZeroProposalTimestamp {
232 proposal_id: u64,
234 },
235
236 #[error(
238 "NNS proposal {proposal_id} topic classification {actual:?} does not match raw code {topic} ({expected:?})"
239 )]
240 TopicClassificationMismatch {
241 proposal_id: u64,
243 topic: i32,
245 actual: NnsProposalTopic,
247 expected: NnsProposalTopic,
249 },
250
251 #[error(
253 "NNS proposal {proposal_id} status classification {actual:?} does not match raw code {status} ({expected:?})"
254 )]
255 StatusClassificationMismatch {
256 proposal_id: u64,
258 status: i32,
260 actual: NnsProposalStatus,
262 expected: NnsProposalStatus,
264 },
265
266 #[error(
268 "NNS proposal {proposal_id} reward-status classification {actual:?} does not match raw code {reward_status} ({expected:?})"
269 )]
270 RewardStatusClassificationMismatch {
271 proposal_id: u64,
273 reward_status: i32,
275 actual: NnsProposalRewardStatus,
277 expected: NnsProposalRewardStatus,
279 },
280
281 #[error("NNS proposal activity accounting overflow while updating {field}")]
283 AccountingOverflow {
284 field: &'static str,
286 },
287}
288
289pub fn validate_nns_proposal_activity_report(
291 report: &NnsProposalActivityReport,
292) -> Result<(), NnsProposalActivityValidationError> {
293 validate_activity_header(report)?;
294 validate_activity_selection(report)?;
295 validate_topic_counts(report)?;
296 validate_status_counts(report)?;
297 validate_reward_status_counts(report)?;
298 validate_day_counts(report)
299}
300
301fn validate_activity_header(
302 report: &NnsProposalActivityReport,
303) -> Result<(), NnsProposalActivityValidationError> {
304 if report.schema_version != NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION {
305 return Err(invalid_validation(format!(
306 "schema version {} does not equal {}",
307 report.schema_version, NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION
308 )));
309 }
310 if report.network != MAINNET_NETWORK {
311 return Err(invalid_validation(format!(
312 "network is {}, expected {MAINNET_NETWORK}",
313 report.network
314 )));
315 }
316 if report.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
317 return Err(invalid_validation(format!(
318 "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
319 report.governance_canister_id
320 )));
321 }
322 if report.collection_page_count == 0 {
323 return Err(invalid_validation(
324 "complete activity report must retain at least one collection page",
325 ));
326 }
327 let minimum_proposal_count = u64::from(report.collection_page_count - 1);
328 if report.collected_proposal_count < minimum_proposal_count {
329 return Err(invalid_validation(format!(
330 "collection_page_count {} requires at least {minimum_proposal_count} collected proposals, found {}",
331 report.collection_page_count, report.collected_proposal_count
332 )));
333 }
334 validate_governance_time_interval(
335 "collection_started_at",
336 &report.collection_started_at,
337 "collection_updated_at",
338 &report.collection_updated_at,
339 )
340 .map_err(|error| invalid_validation(error.to_string()))?;
341 if report.point_in_time_guaranteed {
342 return Err(invalid_validation(
343 "sequential proposal activity cannot claim a point-in-time snapshot",
344 ));
345 }
346
347 validate_governance_report_source(&report.network, &report.source).map_err(|error| {
348 let context = match &report.source {
349 NnsGovernanceSourceProvenance::ReplicaQuery { .. } => "source",
350 NnsGovernanceSourceProvenance::ReplicatedInterCanisterCall { .. } => "provenance",
351 };
352 invalid_validation(format!("invalid collection {context}: {error}"))
353 })
354}
355
356fn validate_activity_selection(
357 report: &NnsProposalActivityReport,
358) -> Result<(), NnsProposalActivityValidationError> {
359 if let (Some(from), Some(until)) = (
360 report.from_proposal_timestamp_seconds,
361 report.until_proposal_timestamp_seconds,
362 ) && from >= until
363 {
364 return Err(invalid_validation(format!(
365 "from proposal timestamp {from} must be below until timestamp {until}"
366 )));
367 }
368 if report.from_proposal_timestamp_seconds.is_none() && report.excluded_before_from_count != 0 {
369 return Err(invalid_validation(
370 "excluded_before_from_count must be zero without a lower bound",
371 ));
372 }
373 if report.until_proposal_timestamp_seconds.is_none()
374 && report.excluded_at_or_after_until_count != 0
375 {
376 return Err(invalid_validation(
377 "excluded_at_or_after_until_count must be zero without an upper bound",
378 ));
379 }
380
381 let accounted = report
382 .included_proposal_count
383 .checked_add(report.excluded_before_from_count)
384 .and_then(|count| count.checked_add(report.excluded_at_or_after_until_count))
385 .ok_or_else(|| invalid_validation("proposal selection count overflow"))?;
386 if accounted != report.collected_proposal_count {
387 return Err(invalid_validation(format!(
388 "selection accounts for {accounted} proposals, expected {}",
389 report.collected_proposal_count
390 )));
391 }
392 validate_included_range(report)
393}
394
395fn validate_included_range(
396 report: &NnsProposalActivityReport,
397) -> Result<(), NnsProposalActivityValidationError> {
398 let (earliest, latest) = match (
399 report.earliest_included_proposal_timestamp_seconds,
400 report.latest_included_proposal_timestamp_seconds,
401 ) {
402 (None, None) if report.included_proposal_count == 0 => return Ok(()),
403 (Some(earliest), Some(latest)) if report.included_proposal_count > 0 => (earliest, latest),
404 _ => {
405 return Err(invalid_validation(
406 "included timestamp range presence disagrees with included_proposal_count",
407 ));
408 }
409 };
410 if earliest == 0 || earliest > latest {
411 return Err(invalid_validation(
412 "included proposal timestamps must be nonzero and ascending",
413 ));
414 }
415 if report
416 .from_proposal_timestamp_seconds
417 .is_some_and(|from| earliest < from)
418 {
419 return Err(invalid_validation(
420 "earliest included proposal timestamp precedes the lower bound",
421 ));
422 }
423 if report
424 .until_proposal_timestamp_seconds
425 .is_some_and(|until| latest >= until)
426 {
427 return Err(invalid_validation(
428 "latest included proposal timestamp reaches or exceeds the upper bound",
429 ));
430 }
431 Ok(())
432}
433
434fn validate_topic_counts(
435 report: &NnsProposalActivityReport,
436) -> Result<(), NnsProposalActivityValidationError> {
437 let mut previous = None;
438 let mut total = 0_u64;
439 for row in &report.topic_counts {
440 if previous.is_some_and(|topic| topic >= row.topic) {
441 return Err(invalid_validation(
442 "topic count rows are not strictly raw-code ordered",
443 ));
444 }
445 if row.topic_text != NnsProposalTopic::from_code(row.topic) {
446 return Err(invalid_validation(format!(
447 "topic classification for raw code {} is inconsistent",
448 row.topic
449 )));
450 }
451 total = add_dimension_count(total, row.proposal_count, "topic")?;
452 previous = Some(row.topic);
453 }
454 validate_dimension_total(total, report.included_proposal_count, "topic")
455}
456
457fn validate_status_counts(
458 report: &NnsProposalActivityReport,
459) -> Result<(), NnsProposalActivityValidationError> {
460 let mut previous = None;
461 let mut total = 0_u64;
462 for row in &report.status_counts {
463 if previous.is_some_and(|status| status >= row.status) {
464 return Err(invalid_validation(
465 "status count rows are not strictly raw-code ordered",
466 ));
467 }
468 if row.status_text != NnsProposalStatus::from_code(row.status) {
469 return Err(invalid_validation(format!(
470 "status classification for raw code {} is inconsistent",
471 row.status
472 )));
473 }
474 total = add_dimension_count(total, row.proposal_count, "status")?;
475 previous = Some(row.status);
476 }
477 validate_dimension_total(total, report.included_proposal_count, "status")
478}
479
480fn validate_reward_status_counts(
481 report: &NnsProposalActivityReport,
482) -> Result<(), NnsProposalActivityValidationError> {
483 let mut previous = None;
484 let mut total = 0_u64;
485 for row in &report.reward_status_counts {
486 if previous.is_some_and(|reward_status| reward_status >= row.reward_status) {
487 return Err(invalid_validation(
488 "reward-status count rows are not strictly raw-code ordered",
489 ));
490 }
491 if row.reward_status_text != NnsProposalRewardStatus::from_code(row.reward_status) {
492 return Err(invalid_validation(format!(
493 "reward-status classification for raw code {} is inconsistent",
494 row.reward_status
495 )));
496 }
497 total = add_dimension_count(total, row.proposal_count, "reward-status")?;
498 previous = Some(row.reward_status);
499 }
500 validate_dimension_total(total, report.included_proposal_count, "reward-status")
501}
502
503fn validate_day_counts(
504 report: &NnsProposalActivityReport,
505) -> Result<(), NnsProposalActivityValidationError> {
506 let mut previous = None;
507 let mut total = 0_u64;
508 for row in &report.day_counts {
509 if row.day_start_timestamp_seconds % SECONDS_PER_DAY != 0 {
510 return Err(invalid_validation(
511 "day count row is not aligned to 00:00:00 UTC",
512 ));
513 }
514 if previous.is_some_and(|day| day >= row.day_start_timestamp_seconds) {
515 return Err(invalid_validation(
516 "day count rows are not strictly time ordered",
517 ));
518 }
519 total = add_dimension_count(total, row.proposal_count, "day")?;
520 previous = Some(row.day_start_timestamp_seconds);
521 }
522 validate_dimension_total(total, report.included_proposal_count, "day")?;
523 validate_day_range(report)
524}
525
526fn validate_day_range(
527 report: &NnsProposalActivityReport,
528) -> Result<(), NnsProposalActivityValidationError> {
529 if report.included_proposal_count == 0 {
530 return Ok(());
531 }
532 let (Some(earliest), Some(latest)) = (
533 report.earliest_included_proposal_timestamp_seconds,
534 report.latest_included_proposal_timestamp_seconds,
535 ) else {
536 return Err(invalid_validation(
537 "included timestamp range is absent for positive day counts",
538 ));
539 };
540 let expected_first = earliest - (earliest % SECONDS_PER_DAY);
541 let expected_last = latest - (latest % SECONDS_PER_DAY);
542 let (Some(first), Some(last)) = (report.day_counts.first(), report.day_counts.last()) else {
543 return Err(invalid_validation(
544 "positive included count requires nonempty day counts",
545 ));
546 };
547 let first = first.day_start_timestamp_seconds;
548 let last = last.day_start_timestamp_seconds;
549 if first != expected_first || last != expected_last {
550 return Err(invalid_validation(
551 "day count endpoints do not cover the included timestamp range",
552 ));
553 }
554 Ok(())
555}
556
557fn add_dimension_count(
558 total: u64,
559 count: u64,
560 dimension: &'static str,
561) -> Result<u64, NnsProposalActivityValidationError> {
562 if count == 0 {
563 return Err(invalid_validation(format!(
564 "{dimension} count row must be nonzero"
565 )));
566 }
567 total
568 .checked_add(count)
569 .ok_or_else(|| invalid_validation(format!("{dimension} count total overflow")))
570}
571
572fn validate_dimension_total(
573 actual: u64,
574 expected: u64,
575 dimension: &'static str,
576) -> Result<(), NnsProposalActivityValidationError> {
577 if actual == expected {
578 Ok(())
579 } else {
580 Err(invalid_validation(format!(
581 "{dimension} counts sum to {actual}, expected {expected}"
582 )))
583 }
584}
585
586fn invalid_validation(reason: impl Into<String>) -> NnsProposalActivityValidationError {
587 NnsProposalActivityValidationError {
588 reason: reason.into(),
589 }
590}
591
592pub fn build_nns_proposal_activity_report(
594 request: &NnsProposalActivityRequest,
595 collection: &NnsProposalCollectionState,
596 proposals: &[NnsProposalRow],
597) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
598 validate_collection_state(collection).map_err(|error| {
599 NnsProposalActivityError::InvalidCollectionState {
600 reason: error.to_string(),
601 }
602 })?;
603 if !collection.is_complete() {
604 return Err(NnsProposalActivityError::CollectionNotComplete {
605 status: collection.status(),
606 });
607 }
608 validate_time_window(request)?;
609
610 let expected = collection.proposals_fetched();
611 let actual = u64::try_from(proposals.len()).map_err(|_| {
612 NnsProposalActivityError::AccountingOverflow {
613 field: "supplied_proposal_count",
614 }
615 })?;
616 if actual != expected {
617 return Err(NnsProposalActivityError::ProposalCountMismatch { expected, actual });
618 }
619
620 let mut activity = ActivityAccumulator::with_capacity(proposals.len());
621 for proposal in proposals {
622 activity.observe(request, proposal)?;
623 }
624
625 let source = collection.source().cloned().ok_or_else(|| {
626 NnsProposalActivityError::InvalidCollectionState {
627 reason: "complete collection has no concrete source provenance".to_string(),
628 }
629 })?;
630 Ok(activity.into_report(request, collection, expected, source))
631}
632
633struct ActivityAccumulator {
634 proposal_ids: HashSet<u64>,
635 topic_counts: BTreeMap<i32, u64>,
636 status_counts: BTreeMap<i32, u64>,
637 reward_status_counts: BTreeMap<i32, u64>,
638 day_counts: BTreeMap<u64, u64>,
639 included_proposal_count: u64,
640 excluded_before_from_count: u64,
641 excluded_at_or_after_until_count: u64,
642 earliest_included_proposal_timestamp_seconds: Option<u64>,
643 latest_included_proposal_timestamp_seconds: Option<u64>,
644}
645
646impl ActivityAccumulator {
647 fn with_capacity(proposal_count: usize) -> Self {
648 Self {
649 proposal_ids: HashSet::with_capacity(proposal_count),
650 topic_counts: BTreeMap::new(),
651 status_counts: BTreeMap::new(),
652 reward_status_counts: BTreeMap::new(),
653 day_counts: BTreeMap::new(),
654 included_proposal_count: 0,
655 excluded_before_from_count: 0,
656 excluded_at_or_after_until_count: 0,
657 earliest_included_proposal_timestamp_seconds: None,
658 latest_included_proposal_timestamp_seconds: None,
659 }
660 }
661
662 fn observe(
663 &mut self,
664 request: &NnsProposalActivityRequest,
665 proposal: &NnsProposalRow,
666 ) -> Result<(), NnsProposalActivityError> {
667 validate_proposal_row(proposal, &mut self.proposal_ids)?;
668 let timestamp = proposal.proposal_timestamp_seconds;
669 if request
670 .from_proposal_timestamp_seconds
671 .is_some_and(|from| timestamp < from)
672 {
673 return increment_count(
674 &mut self.excluded_before_from_count,
675 "excluded_before_from_count",
676 );
677 }
678 if request
679 .until_proposal_timestamp_seconds
680 .is_some_and(|until| timestamp >= until)
681 {
682 return increment_count(
683 &mut self.excluded_at_or_after_until_count,
684 "excluded_at_or_after_until_count",
685 );
686 }
687
688 increment_count(&mut self.included_proposal_count, "included_proposal_count")?;
689 increment_count(
690 self.topic_counts.entry(proposal.topic).or_default(),
691 "topic_count",
692 )?;
693 increment_count(
694 self.status_counts.entry(proposal.status).or_default(),
695 "status_count",
696 )?;
697 increment_count(
698 self.reward_status_counts
699 .entry(proposal.reward_status)
700 .or_default(),
701 "reward_status_count",
702 )?;
703 let day_start = timestamp - (timestamp % SECONDS_PER_DAY);
704 increment_count(self.day_counts.entry(day_start).or_default(), "day_count")?;
705 self.earliest_included_proposal_timestamp_seconds = Some(
706 self.earliest_included_proposal_timestamp_seconds
707 .map_or(timestamp, |earliest| earliest.min(timestamp)),
708 );
709 self.latest_included_proposal_timestamp_seconds = Some(
710 self.latest_included_proposal_timestamp_seconds
711 .map_or(timestamp, |latest| latest.max(timestamp)),
712 );
713 Ok(())
714 }
715
716 fn into_report(
717 self,
718 request: &NnsProposalActivityRequest,
719 collection: &NnsProposalCollectionState,
720 collected_proposal_count: u64,
721 source: NnsGovernanceSourceProvenance,
722 ) -> NnsProposalActivityReport {
723 NnsProposalActivityReport {
724 schema_version: NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION,
725 network: collection.network().to_string(),
726 governance_canister_id: collection.governance_canister_id().to_string(),
727 source,
728 collection_started_at: collection.started_at().to_string(),
729 collection_updated_at: collection.updated_at().to_string(),
730 collection_page_count: collection.pages_fetched(),
731 collected_proposal_count,
732 point_in_time_guaranteed: false,
733 from_proposal_timestamp_seconds: request.from_proposal_timestamp_seconds,
734 until_proposal_timestamp_seconds: request.until_proposal_timestamp_seconds,
735 included_proposal_count: self.included_proposal_count,
736 excluded_before_from_count: self.excluded_before_from_count,
737 excluded_at_or_after_until_count: self.excluded_at_or_after_until_count,
738 earliest_included_proposal_timestamp_seconds: self
739 .earliest_included_proposal_timestamp_seconds,
740 latest_included_proposal_timestamp_seconds: self
741 .latest_included_proposal_timestamp_seconds,
742 topic_counts: self
743 .topic_counts
744 .into_iter()
745 .map(|(topic, proposal_count)| NnsProposalTopicCount {
746 topic,
747 topic_text: NnsProposalTopic::from_code(topic),
748 proposal_count,
749 })
750 .collect(),
751 status_counts: self
752 .status_counts
753 .into_iter()
754 .map(|(status, proposal_count)| NnsProposalStatusCount {
755 status,
756 status_text: NnsProposalStatus::from_code(status),
757 proposal_count,
758 })
759 .collect(),
760 reward_status_counts: self
761 .reward_status_counts
762 .into_iter()
763 .map(
764 |(reward_status, proposal_count)| NnsProposalRewardStatusCount {
765 reward_status,
766 reward_status_text: NnsProposalRewardStatus::from_code(reward_status),
767 proposal_count,
768 },
769 )
770 .collect(),
771 day_counts: self
772 .day_counts
773 .into_iter()
774 .map(
775 |(day_start_timestamp_seconds, proposal_count)| NnsProposalDayCount {
776 day_start_timestamp_seconds,
777 proposal_count,
778 },
779 )
780 .collect(),
781 }
782 }
783}
784
785pub(super) const fn validate_time_window(
786 request: &NnsProposalActivityRequest,
787) -> Result<(), NnsProposalActivityError> {
788 if let (Some(from), Some(until)) = (
789 request.from_proposal_timestamp_seconds,
790 request.until_proposal_timestamp_seconds,
791 ) && from >= until
792 {
793 return Err(NnsProposalActivityError::InvalidTimeWindow {
794 from_proposal_timestamp_seconds: from,
795 until_proposal_timestamp_seconds: until,
796 });
797 }
798 Ok(())
799}
800
801fn validate_proposal_row(
802 proposal: &NnsProposalRow,
803 proposal_ids: &mut HashSet<u64>,
804) -> Result<(), NnsProposalActivityError> {
805 let proposal_id = proposal
806 .proposal_id
807 .ok_or(NnsProposalActivityError::MissingProposalId)?;
808 if proposal_id == 0 {
809 return Err(NnsProposalActivityError::ZeroProposalId);
810 }
811 if !proposal_ids.insert(proposal_id) {
812 return Err(NnsProposalActivityError::DuplicateProposalId { proposal_id });
813 }
814 if proposal.proposal_timestamp_seconds == 0 {
815 return Err(NnsProposalActivityError::ZeroProposalTimestamp { proposal_id });
816 }
817
818 let expected_topic = NnsProposalTopic::from_code(proposal.topic);
819 if proposal.topic_text != expected_topic {
820 return Err(NnsProposalActivityError::TopicClassificationMismatch {
821 proposal_id,
822 topic: proposal.topic,
823 actual: proposal.topic_text,
824 expected: expected_topic,
825 });
826 }
827 let expected_status = NnsProposalStatus::from_code(proposal.status);
828 if proposal.status_text != expected_status {
829 return Err(NnsProposalActivityError::StatusClassificationMismatch {
830 proposal_id,
831 status: proposal.status,
832 actual: proposal.status_text,
833 expected: expected_status,
834 });
835 }
836 let expected_reward_status = NnsProposalRewardStatus::from_code(proposal.reward_status);
837 if proposal.reward_status_text != expected_reward_status {
838 return Err(
839 NnsProposalActivityError::RewardStatusClassificationMismatch {
840 proposal_id,
841 reward_status: proposal.reward_status,
842 actual: proposal.reward_status_text,
843 expected: expected_reward_status,
844 },
845 );
846 }
847 Ok(())
848}
849
850fn increment_count(count: &mut u64, field: &'static str) -> Result<(), NnsProposalActivityError> {
851 *count = count
852 .checked_add(1)
853 .ok_or(NnsProposalActivityError::AccountingOverflow { field })?;
854 Ok(())
855}
856
857#[cfg(test)]
858mod tests;