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