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 NnsGovernanceRequest, NnsGovernanceSourceProvenance, NnsGovernanceSourceSelection,
17 validate_governance_request, validate_source_provenance,
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 if report.point_in_time_guaranteed {
332 return Err(invalid_validation(
333 "sequential proposal activity cannot claim a point-in-time snapshot",
334 ));
335 }
336
337 let source = match &report.source {
338 NnsGovernanceSourceProvenance::ReplicaQuery {
339 endpoint,
340 fetched_by,
341 } => NnsGovernanceSourceSelection::ReplicaQuery {
342 endpoint: endpoint.clone(),
343 fetched_by: fetched_by.clone(),
344 },
345 NnsGovernanceSourceProvenance::ReplicatedInterCanisterCall { .. } => {
346 NnsGovernanceSourceSelection::ReplicatedInterCanisterCall
347 }
348 };
349 let request = NnsGovernanceRequest {
350 network: report.network.clone(),
351 fetched_at: report.collection_started_at.clone(),
352 source,
353 };
354 validate_governance_request(&request)
355 .map_err(|error| invalid_validation(format!("invalid collection source: {error}")))?;
356 validate_source_provenance(&request.source, &report.source)
357 .map_err(|error| invalid_validation(format!("invalid collection provenance: {error}")))
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 = u64::try_from(collection.proposals_fetched()).map_err(|_| {
615 NnsProposalActivityError::AccountingOverflow {
616 field: "collected_proposal_count",
617 }
618 })?;
619 let actual = u64::try_from(proposals.len()).map_err(|_| {
620 NnsProposalActivityError::AccountingOverflow {
621 field: "supplied_proposal_count",
622 }
623 })?;
624 if actual != expected {
625 return Err(NnsProposalActivityError::ProposalCountMismatch { expected, actual });
626 }
627
628 let mut activity = ActivityAccumulator::with_capacity(proposals.len());
629 for proposal in proposals {
630 activity.observe(request, proposal)?;
631 }
632
633 let source = collection.source().cloned().ok_or_else(|| {
634 NnsProposalActivityError::InvalidCollectionState {
635 reason: "complete collection has no concrete source provenance".to_string(),
636 }
637 })?;
638 let report = activity.into_report(request, collection, expected, source);
639 validate_nns_proposal_activity_report(&report)?;
640 Ok(report)
641}
642
643struct ActivityAccumulator {
644 proposal_ids: HashSet<u64>,
645 topic_counts: BTreeMap<i32, u64>,
646 status_counts: BTreeMap<i32, u64>,
647 reward_status_counts: BTreeMap<i32, u64>,
648 day_counts: BTreeMap<u64, u64>,
649 included_proposal_count: u64,
650 excluded_before_from_count: u64,
651 excluded_at_or_after_until_count: u64,
652 earliest_included_proposal_timestamp_seconds: Option<u64>,
653 latest_included_proposal_timestamp_seconds: Option<u64>,
654}
655
656impl ActivityAccumulator {
657 fn with_capacity(proposal_count: usize) -> Self {
658 Self {
659 proposal_ids: HashSet::with_capacity(proposal_count),
660 topic_counts: BTreeMap::new(),
661 status_counts: BTreeMap::new(),
662 reward_status_counts: BTreeMap::new(),
663 day_counts: BTreeMap::new(),
664 included_proposal_count: 0,
665 excluded_before_from_count: 0,
666 excluded_at_or_after_until_count: 0,
667 earliest_included_proposal_timestamp_seconds: None,
668 latest_included_proposal_timestamp_seconds: None,
669 }
670 }
671
672 fn observe(
673 &mut self,
674 request: &NnsProposalActivityRequest,
675 proposal: &NnsProposalRow,
676 ) -> Result<(), NnsProposalActivityError> {
677 validate_proposal_row(proposal, &mut self.proposal_ids)?;
678 let timestamp = proposal.proposal_timestamp_seconds;
679 if request
680 .from_proposal_timestamp_seconds
681 .is_some_and(|from| timestamp < from)
682 {
683 return increment_count(
684 &mut self.excluded_before_from_count,
685 "excluded_before_from_count",
686 );
687 }
688 if request
689 .until_proposal_timestamp_seconds
690 .is_some_and(|until| timestamp >= until)
691 {
692 return increment_count(
693 &mut self.excluded_at_or_after_until_count,
694 "excluded_at_or_after_until_count",
695 );
696 }
697
698 increment_count(&mut self.included_proposal_count, "included_proposal_count")?;
699 increment_count(
700 self.topic_counts.entry(proposal.topic).or_default(),
701 "topic_count",
702 )?;
703 increment_count(
704 self.status_counts.entry(proposal.status).or_default(),
705 "status_count",
706 )?;
707 increment_count(
708 self.reward_status_counts
709 .entry(proposal.reward_status)
710 .or_default(),
711 "reward_status_count",
712 )?;
713 let day_start = timestamp - (timestamp % SECONDS_PER_DAY);
714 increment_count(self.day_counts.entry(day_start).or_default(), "day_count")?;
715 self.earliest_included_proposal_timestamp_seconds = Some(
716 self.earliest_included_proposal_timestamp_seconds
717 .map_or(timestamp, |earliest| earliest.min(timestamp)),
718 );
719 self.latest_included_proposal_timestamp_seconds = Some(
720 self.latest_included_proposal_timestamp_seconds
721 .map_or(timestamp, |latest| latest.max(timestamp)),
722 );
723 Ok(())
724 }
725
726 fn into_report(
727 self,
728 request: &NnsProposalActivityRequest,
729 collection: &NnsProposalCollectionState,
730 collected_proposal_count: u64,
731 source: NnsGovernanceSourceProvenance,
732 ) -> NnsProposalActivityReport {
733 NnsProposalActivityReport {
734 schema_version: NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION,
735 network: collection.network().to_string(),
736 governance_canister_id: collection.governance_canister_id().to_string(),
737 source,
738 collection_started_at: collection.started_at().to_string(),
739 collection_updated_at: collection.updated_at().to_string(),
740 collection_page_count: collection.pages_fetched(),
741 collected_proposal_count,
742 point_in_time_guaranteed: false,
743 from_proposal_timestamp_seconds: request.from_proposal_timestamp_seconds,
744 until_proposal_timestamp_seconds: request.until_proposal_timestamp_seconds,
745 included_proposal_count: self.included_proposal_count,
746 excluded_before_from_count: self.excluded_before_from_count,
747 excluded_at_or_after_until_count: self.excluded_at_or_after_until_count,
748 earliest_included_proposal_timestamp_seconds: self
749 .earliest_included_proposal_timestamp_seconds,
750 latest_included_proposal_timestamp_seconds: self
751 .latest_included_proposal_timestamp_seconds,
752 topic_counts: self
753 .topic_counts
754 .into_iter()
755 .map(|(topic, proposal_count)| NnsProposalTopicCount {
756 topic,
757 topic_text: NnsProposalTopic::from_code(topic),
758 proposal_count,
759 })
760 .collect(),
761 status_counts: self
762 .status_counts
763 .into_iter()
764 .map(|(status, proposal_count)| NnsProposalStatusCount {
765 status,
766 status_text: NnsProposalStatus::from_code(status),
767 proposal_count,
768 })
769 .collect(),
770 reward_status_counts: self
771 .reward_status_counts
772 .into_iter()
773 .map(
774 |(reward_status, proposal_count)| NnsProposalRewardStatusCount {
775 reward_status,
776 reward_status_text: NnsProposalRewardStatus::from_code(reward_status),
777 proposal_count,
778 },
779 )
780 .collect(),
781 day_counts: self
782 .day_counts
783 .into_iter()
784 .map(
785 |(day_start_timestamp_seconds, proposal_count)| NnsProposalDayCount {
786 day_start_timestamp_seconds,
787 proposal_count,
788 },
789 )
790 .collect(),
791 }
792 }
793}
794
795const fn validate_time_window(
796 request: &NnsProposalActivityRequest,
797) -> Result<(), NnsProposalActivityError> {
798 if let (Some(from), Some(until)) = (
799 request.from_proposal_timestamp_seconds,
800 request.until_proposal_timestamp_seconds,
801 ) && from >= until
802 {
803 return Err(NnsProposalActivityError::InvalidTimeWindow {
804 from_proposal_timestamp_seconds: from,
805 until_proposal_timestamp_seconds: until,
806 });
807 }
808 Ok(())
809}
810
811fn validate_proposal_row(
812 proposal: &NnsProposalRow,
813 proposal_ids: &mut HashSet<u64>,
814) -> Result<(), NnsProposalActivityError> {
815 let proposal_id = proposal
816 .proposal_id
817 .ok_or(NnsProposalActivityError::MissingProposalId)?;
818 if proposal_id == 0 {
819 return Err(NnsProposalActivityError::ZeroProposalId);
820 }
821 if !proposal_ids.insert(proposal_id) {
822 return Err(NnsProposalActivityError::DuplicateProposalId { proposal_id });
823 }
824 if proposal.proposal_timestamp_seconds == 0 {
825 return Err(NnsProposalActivityError::ZeroProposalTimestamp { proposal_id });
826 }
827
828 let expected_topic = NnsProposalTopic::from_code(proposal.topic);
829 if proposal.topic_text != expected_topic {
830 return Err(NnsProposalActivityError::TopicClassificationMismatch {
831 proposal_id,
832 topic: proposal.topic,
833 actual: proposal.topic_text,
834 expected: expected_topic,
835 });
836 }
837 let expected_status = NnsProposalStatus::from_code(proposal.status);
838 if proposal.status_text != expected_status {
839 return Err(NnsProposalActivityError::StatusClassificationMismatch {
840 proposal_id,
841 status: proposal.status,
842 actual: proposal.status_text,
843 expected: expected_status,
844 });
845 }
846 let expected_reward_status = NnsProposalRewardStatus::from_code(proposal.reward_status);
847 if proposal.reward_status_text != expected_reward_status {
848 return Err(
849 NnsProposalActivityError::RewardStatusClassificationMismatch {
850 proposal_id,
851 reward_status: proposal.reward_status,
852 actual: proposal.reward_status_text,
853 expected: expected_reward_status,
854 },
855 );
856 }
857 Ok(())
858}
859
860fn increment_count(count: &mut u64, field: &'static str) -> Result<(), NnsProposalActivityError> {
861 *count = count
862 .checked_add(1)
863 .ok_or(NnsProposalActivityError::AccountingOverflow { field })?;
864 Ok(())
865}
866
867#[cfg(test)]
868mod tests;