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