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