Skip to main content

ic_query/nns/proposals/report/activity/
mod.rs

1//! Module: nns::proposals::report::activity
2//!
3//! Responsibility: validate and aggregate complete caller-retained NNS proposal collections.
4//! Does not own: collection transport, persistence, cache policy, or process output.
5//! Boundary: projects complete proposal evidence into deterministic portable activity reports.
6
7use super::{
8    NnsProposalCollectionState, collection::validate_collection_state, model::NnsProposalRow,
9};
10use crate::{
11    nns::{
12        MAINNET_GOVERNANCE_CANISTER_ID,
13        governance::{
14            NnsGovernanceCollectionStatus, NnsGovernanceSourceProvenance,
15            validate_governance_report_source, validate_governance_time_interval,
16        },
17    },
18    subnet_catalog::MAINNET_NETWORK,
19};
20use serde::{Deserialize, Serialize};
21use std::collections::{BTreeMap, HashSet};
22use thiserror::Error as ThisError;
23
24/// Version of the portable NNS proposal activity report schema.
25pub const NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION: u32 = 1;
26
27const SECONDS_PER_DAY: u64 = 86_400;
28
29///
30/// NnsProposalActivityRequest
31///
32/// Optional half-open proposal-creation time window for one local activity projection.
33///
34
35#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
36pub struct NnsProposalActivityRequest {
37    /// Inclusive lower proposal-creation timestamp bound.
38    pub from_proposal_timestamp_seconds: Option<u64>,
39    /// Exclusive upper proposal-creation timestamp bound.
40    pub until_proposal_timestamp_seconds: Option<u64>,
41}
42
43///
44/// NnsProposalTopicCount
45///
46/// Proposal count for one raw native Governance topic code.
47///
48
49#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
50#[serde(deny_unknown_fields)]
51pub struct NnsProposalTopicCount {
52    /// Raw native Governance topic code.
53    pub topic: i32,
54    /// Number of included proposals with this topic code.
55    pub proposal_count: u64,
56}
57
58///
59/// NnsProposalStatusCount
60///
61/// Proposal count for one raw native Governance decision-status code.
62///
63
64#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
65#[serde(deny_unknown_fields)]
66pub struct NnsProposalStatusCount {
67    /// Raw native Governance decision-status code.
68    pub status: i32,
69    /// Number of included proposals with this status code.
70    pub proposal_count: u64,
71}
72
73///
74/// NnsProposalRewardStatusCount
75///
76/// Proposal count for one raw native Governance reward-status code.
77///
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(deny_unknown_fields)]
81pub struct NnsProposalRewardStatusCount {
82    /// Raw native Governance reward-status code.
83    pub reward_status: i32,
84    /// Number of included proposals with this reward-status code.
85    pub proposal_count: u64,
86}
87
88///
89/// NnsProposalDayCount
90///
91/// Proposal count for one UTC proposal-creation day.
92///
93
94#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
95pub struct NnsProposalDayCount {
96    /// Unix timestamp at 00:00:00 UTC for the represented day.
97    pub day_start_timestamp_seconds: u64,
98    /// Number of included proposals created during this UTC day.
99    pub proposal_count: u64,
100}
101
102///
103/// NnsProposalActivityReport
104///
105/// Deterministic local activity projection over one complete NNS proposal collection.
106///
107
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109pub struct NnsProposalActivityReport {
110    /// Report schema version.
111    pub schema_version: u32,
112    /// Network identity retained by the complete collection.
113    pub network: String,
114    /// Fixed NNS Governance canister principal retained by the collection.
115    pub governance_canister_id: String,
116    /// Concrete transport and collector provenance retained by the collection.
117    pub source: NnsGovernanceSourceProvenance,
118    /// Caller-supplied time attached to collection creation.
119    pub collection_started_at: String,
120    /// Caller-supplied time attached to the final admitted page.
121    pub collection_updated_at: String,
122    /// Number of admitted source pages in the complete collection.
123    pub collection_page_count: u32,
124    /// Number of admitted proposal rows in the complete collection.
125    pub collected_proposal_count: u64,
126    /// Whether the sequential collection is guaranteed to represent one point in time.
127    pub point_in_time_guaranteed: bool,
128    /// Inclusive lower proposal-creation timestamp bound requested by the caller.
129    pub from_proposal_timestamp_seconds: Option<u64>,
130    /// Exclusive upper proposal-creation timestamp bound requested by the caller.
131    pub until_proposal_timestamp_seconds: Option<u64>,
132    /// Number of proposals included by the local time window.
133    pub included_proposal_count: u64,
134    /// Number of proposals excluded before the inclusive lower bound.
135    pub excluded_before_from_count: u64,
136    /// Number of proposals excluded at or after the exclusive upper bound.
137    pub excluded_at_or_after_until_count: u64,
138    /// Earliest creation timestamp among included proposals.
139    pub earliest_included_proposal_timestamp_seconds: Option<u64>,
140    /// Latest creation timestamp among included proposals.
141    pub latest_included_proposal_timestamp_seconds: Option<u64>,
142    /// Canonically raw-code-ordered proposal counts by topic.
143    pub topic_counts: Vec<NnsProposalTopicCount>,
144    /// Canonically raw-code-ordered proposal counts by decision status.
145    pub status_counts: Vec<NnsProposalStatusCount>,
146    /// Canonically raw-code-ordered proposal counts by reward status.
147    pub reward_status_counts: Vec<NnsProposalRewardStatusCount>,
148    /// Canonically time-ordered proposal counts by UTC creation day.
149    pub day_counts: Vec<NnsProposalDayCount>,
150}
151
152///
153/// NnsProposalActivityValidationError
154///
155/// Pure validation failure for an untrusted serialized or in-memory activity report.
156///
157
158#[derive(Debug, Eq, PartialEq, ThisError)]
159#[error("invalid NNS proposal activity report: {reason}")]
160pub struct NnsProposalActivityValidationError {
161    /// Deterministic invariant failure.
162    pub reason: String,
163}
164
165///
166/// NnsProposalActivityError
167///
168/// Deterministic validation or accounting failure from local proposal activity projection.
169///
170
171#[derive(Debug, ThisError)]
172pub enum NnsProposalActivityError {
173    /// The supplied collection state failed its shared continuation invariants.
174    #[error("invalid NNS proposal collection state for activity projection: {reason}")]
175    InvalidCollectionState {
176        /// Deterministic collection invariant failure.
177        reason: String,
178    },
179
180    /// The collection stopped without observing Governance API exhaustion.
181    #[error("NNS proposal activity requires a complete collection; state is {status}")]
182    CollectionNotComplete {
183        /// Current lifecycle of the otherwise valid collection state.
184        status: NnsGovernanceCollectionStatus,
185    },
186
187    /// The requested half-open proposal time window is empty or reversed.
188    #[error(
189        "invalid NNS proposal activity time window: from {from_proposal_timestamp_seconds} must be below until {until_proposal_timestamp_seconds}"
190    )]
191    InvalidTimeWindow {
192        /// Inclusive lower proposal-creation timestamp bound.
193        from_proposal_timestamp_seconds: u64,
194        /// Exclusive upper proposal-creation timestamp bound.
195        until_proposal_timestamp_seconds: u64,
196    },
197
198    /// The supplied rows do not match the collection's admitted-row accounting.
199    #[error(
200        "NNS proposal activity received {actual} rows; complete collection accounts for {expected}"
201    )]
202    ProposalCountMismatch {
203        /// Proposal rows accounted for by the collection state.
204        expected: u64,
205        /// Proposal rows supplied to the builder.
206        actual: u64,
207    },
208
209    /// A supplied row has no proposal identifier.
210    #[error("NNS proposal activity received a row without a proposal id")]
211    MissingProposalId,
212
213    /// A supplied row uses the reserved zero proposal identifier.
214    #[error("NNS proposal activity received proposal id zero")]
215    ZeroProposalId,
216
217    /// A supplied proposal identifier occurs more than once.
218    #[error("NNS proposal activity received duplicate proposal id {proposal_id}")]
219    DuplicateProposalId {
220        /// Repeated proposal identifier.
221        proposal_id: u64,
222    },
223
224    /// A supplied proposal has no meaningful creation timestamp.
225    #[error("NNS proposal {proposal_id} has proposal timestamp zero")]
226    ZeroProposalTimestamp {
227        /// Proposal identifier attached to the zero timestamp.
228        proposal_id: u64,
229    },
230
231    /// A row-count conversion or aggregate increment exceeded `u64`.
232    #[error("NNS proposal activity accounting overflow while updating {field}")]
233    AccountingOverflow {
234        /// Count or conversion that exceeded its representation.
235        field: &'static str,
236    },
237}
238
239/// Validate every activity-report invariant available without source rows or live host calls.
240pub fn validate_nns_proposal_activity_report(
241    report: &NnsProposalActivityReport,
242) -> Result<(), NnsProposalActivityValidationError> {
243    validate_activity_header(report)?;
244    validate_activity_selection(report)?;
245    validate_code_counts(
246        report,
247        "topic",
248        report
249            .topic_counts
250            .iter()
251            .map(|row| (row.topic, row.proposal_count)),
252    )?;
253    validate_code_counts(
254        report,
255        "status",
256        report
257            .status_counts
258            .iter()
259            .map(|row| (row.status, row.proposal_count)),
260    )?;
261    validate_code_counts(
262        report,
263        "reward-status",
264        report
265            .reward_status_counts
266            .iter()
267            .map(|row| (row.reward_status, row.proposal_count)),
268    )?;
269    validate_day_counts(report)
270}
271
272fn validate_activity_header(
273    report: &NnsProposalActivityReport,
274) -> Result<(), NnsProposalActivityValidationError> {
275    if report.schema_version != NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION {
276        return Err(invalid_validation(format!(
277            "schema version {} does not equal {}",
278            report.schema_version, NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION
279        )));
280    }
281    if report.network != MAINNET_NETWORK {
282        return Err(invalid_validation(format!(
283            "network is {}, expected {MAINNET_NETWORK}",
284            report.network
285        )));
286    }
287    if report.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
288        return Err(invalid_validation(format!(
289            "governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
290            report.governance_canister_id
291        )));
292    }
293    if report.collection_page_count == 0 {
294        return Err(invalid_validation(
295            "complete activity report must retain at least one collection page",
296        ));
297    }
298    let minimum_proposal_count = u64::from(report.collection_page_count - 1);
299    if report.collected_proposal_count < minimum_proposal_count {
300        return Err(invalid_validation(format!(
301            "collection_page_count {} requires at least {minimum_proposal_count} collected proposals, found {}",
302            report.collection_page_count, report.collected_proposal_count
303        )));
304    }
305    validate_governance_time_interval(
306        "collection_started_at",
307        &report.collection_started_at,
308        "collection_updated_at",
309        &report.collection_updated_at,
310    )
311    .map_err(|error| invalid_validation(error.to_string()))?;
312    if report.point_in_time_guaranteed {
313        return Err(invalid_validation(
314            "sequential proposal activity cannot claim a point-in-time snapshot",
315        ));
316    }
317
318    validate_governance_report_source(&report.network, &report.source).map_err(|error| {
319        let context = match &report.source {
320            NnsGovernanceSourceProvenance::ReplicaQuery { .. } => "source",
321            NnsGovernanceSourceProvenance::ReplicatedInterCanisterCall { .. } => "provenance",
322        };
323        invalid_validation(format!("invalid collection {context}: {error}"))
324    })
325}
326
327fn validate_activity_selection(
328    report: &NnsProposalActivityReport,
329) -> Result<(), NnsProposalActivityValidationError> {
330    if let (Some(from), Some(until)) = (
331        report.from_proposal_timestamp_seconds,
332        report.until_proposal_timestamp_seconds,
333    ) && from >= until
334    {
335        return Err(invalid_validation(format!(
336            "from proposal timestamp {from} must be below until timestamp {until}"
337        )));
338    }
339    if report.from_proposal_timestamp_seconds.is_none() && report.excluded_before_from_count != 0 {
340        return Err(invalid_validation(
341            "excluded_before_from_count must be zero without a lower bound",
342        ));
343    }
344    if report.until_proposal_timestamp_seconds.is_none()
345        && report.excluded_at_or_after_until_count != 0
346    {
347        return Err(invalid_validation(
348            "excluded_at_or_after_until_count must be zero without an upper bound",
349        ));
350    }
351
352    let accounted = report
353        .included_proposal_count
354        .checked_add(report.excluded_before_from_count)
355        .and_then(|count| count.checked_add(report.excluded_at_or_after_until_count))
356        .ok_or_else(|| invalid_validation("proposal selection count overflow"))?;
357    if accounted != report.collected_proposal_count {
358        return Err(invalid_validation(format!(
359            "selection accounts for {accounted} proposals, expected {}",
360            report.collected_proposal_count
361        )));
362    }
363    validate_included_range(report)
364}
365
366fn validate_included_range(
367    report: &NnsProposalActivityReport,
368) -> Result<(), NnsProposalActivityValidationError> {
369    let (earliest, latest) = match (
370        report.earliest_included_proposal_timestamp_seconds,
371        report.latest_included_proposal_timestamp_seconds,
372    ) {
373        (None, None) if report.included_proposal_count == 0 => return Ok(()),
374        (Some(earliest), Some(latest)) if report.included_proposal_count > 0 => (earliest, latest),
375        _ => {
376            return Err(invalid_validation(
377                "included timestamp range presence disagrees with included_proposal_count",
378            ));
379        }
380    };
381    if earliest == 0 || earliest > latest {
382        return Err(invalid_validation(
383            "included proposal timestamps must be nonzero and ascending",
384        ));
385    }
386    if report
387        .from_proposal_timestamp_seconds
388        .is_some_and(|from| earliest < from)
389    {
390        return Err(invalid_validation(
391            "earliest included proposal timestamp precedes the lower bound",
392        ));
393    }
394    if report
395        .until_proposal_timestamp_seconds
396        .is_some_and(|until| latest >= until)
397    {
398        return Err(invalid_validation(
399            "latest included proposal timestamp reaches or exceeds the upper bound",
400        ));
401    }
402    Ok(())
403}
404
405fn validate_code_counts(
406    report: &NnsProposalActivityReport,
407    dimension: &'static str,
408    rows: impl Iterator<Item = (i32, u64)>,
409) -> Result<(), NnsProposalActivityValidationError> {
410    let mut previous = None;
411    let mut total = 0_u64;
412    for (key, count) in rows {
413        if previous.is_some_and(|previous| previous >= key) {
414            return Err(invalid_validation(format!(
415                "{dimension} count rows are not strictly raw-code ordered"
416            )));
417        }
418        total = add_dimension_count(total, count, dimension)?;
419        previous = Some(key);
420    }
421    validate_dimension_total(total, report.included_proposal_count, dimension)
422}
423
424fn validate_day_counts(
425    report: &NnsProposalActivityReport,
426) -> Result<(), NnsProposalActivityValidationError> {
427    let mut previous = None;
428    let mut total = 0_u64;
429    for row in &report.day_counts {
430        if row.day_start_timestamp_seconds % SECONDS_PER_DAY != 0 {
431            return Err(invalid_validation(
432                "day count row is not aligned to 00:00:00 UTC",
433            ));
434        }
435        if previous.is_some_and(|day| day >= row.day_start_timestamp_seconds) {
436            return Err(invalid_validation(
437                "day count rows are not strictly time ordered",
438            ));
439        }
440        total = add_dimension_count(total, row.proposal_count, "day")?;
441        previous = Some(row.day_start_timestamp_seconds);
442    }
443    validate_dimension_total(total, report.included_proposal_count, "day")?;
444    validate_day_range(report)
445}
446
447fn validate_day_range(
448    report: &NnsProposalActivityReport,
449) -> Result<(), NnsProposalActivityValidationError> {
450    if report.included_proposal_count == 0 {
451        return Ok(());
452    }
453    let (Some(earliest), Some(latest)) = (
454        report.earliest_included_proposal_timestamp_seconds,
455        report.latest_included_proposal_timestamp_seconds,
456    ) else {
457        return Err(invalid_validation(
458            "included timestamp range is absent for positive day counts",
459        ));
460    };
461    let expected_first = earliest - (earliest % SECONDS_PER_DAY);
462    let expected_last = latest - (latest % SECONDS_PER_DAY);
463    let (Some(first), Some(last)) = (report.day_counts.first(), report.day_counts.last()) else {
464        return Err(invalid_validation(
465            "positive included count requires nonempty day counts",
466        ));
467    };
468    let first = first.day_start_timestamp_seconds;
469    let last = last.day_start_timestamp_seconds;
470    if first != expected_first || last != expected_last {
471        return Err(invalid_validation(
472            "day count endpoints do not cover the included timestamp range",
473        ));
474    }
475    Ok(())
476}
477
478fn add_dimension_count(
479    total: u64,
480    count: u64,
481    dimension: &'static str,
482) -> Result<u64, NnsProposalActivityValidationError> {
483    if count == 0 {
484        return Err(invalid_validation(format!(
485            "{dimension} count row must be nonzero"
486        )));
487    }
488    total
489        .checked_add(count)
490        .ok_or_else(|| invalid_validation(format!("{dimension} count total overflow")))
491}
492
493fn validate_dimension_total(
494    actual: u64,
495    expected: u64,
496    dimension: &'static str,
497) -> Result<(), NnsProposalActivityValidationError> {
498    if actual == expected {
499        Ok(())
500    } else {
501        Err(invalid_validation(format!(
502            "{dimension} counts sum to {actual}, expected {expected}"
503        )))
504    }
505}
506
507fn invalid_validation(reason: impl Into<String>) -> NnsProposalActivityValidationError {
508    NnsProposalActivityValidationError {
509        reason: reason.into(),
510    }
511}
512
513/// Build one deterministic activity report from a complete caller-retained proposal collection.
514pub fn build_nns_proposal_activity_report(
515    request: &NnsProposalActivityRequest,
516    collection: &NnsProposalCollectionState,
517    proposals: &[NnsProposalRow],
518) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
519    validate_collection_state(collection).map_err(|error| {
520        NnsProposalActivityError::InvalidCollectionState {
521            reason: error.to_string(),
522        }
523    })?;
524    if !collection.is_complete() {
525        return Err(NnsProposalActivityError::CollectionNotComplete {
526            status: collection.status(),
527        });
528    }
529    validate_time_window(request)?;
530
531    let expected = collection.proposals_fetched();
532    let actual = u64::try_from(proposals.len()).map_err(|_| {
533        NnsProposalActivityError::AccountingOverflow {
534            field: "supplied_proposal_count",
535        }
536    })?;
537    if actual != expected {
538        return Err(NnsProposalActivityError::ProposalCountMismatch { expected, actual });
539    }
540
541    let mut activity = ActivityAccumulator::with_capacity(proposals.len());
542    for proposal in proposals {
543        activity.observe(request, proposal)?;
544    }
545
546    let source = collection.source().cloned().ok_or_else(|| {
547        NnsProposalActivityError::InvalidCollectionState {
548            reason: "complete collection has no concrete source provenance".to_string(),
549        }
550    })?;
551    Ok(activity.into_report(request, collection, expected, source))
552}
553
554struct ActivityAccumulator {
555    proposal_ids: HashSet<u64>,
556    topic_counts: BTreeMap<i32, u64>,
557    status_counts: BTreeMap<i32, u64>,
558    reward_status_counts: BTreeMap<i32, u64>,
559    day_counts: BTreeMap<u64, u64>,
560    included_proposal_count: u64,
561    excluded_before_from_count: u64,
562    excluded_at_or_after_until_count: u64,
563    earliest_included_proposal_timestamp_seconds: Option<u64>,
564    latest_included_proposal_timestamp_seconds: Option<u64>,
565}
566
567impl ActivityAccumulator {
568    fn with_capacity(proposal_count: usize) -> Self {
569        Self {
570            proposal_ids: HashSet::with_capacity(proposal_count),
571            topic_counts: BTreeMap::new(),
572            status_counts: BTreeMap::new(),
573            reward_status_counts: BTreeMap::new(),
574            day_counts: BTreeMap::new(),
575            included_proposal_count: 0,
576            excluded_before_from_count: 0,
577            excluded_at_or_after_until_count: 0,
578            earliest_included_proposal_timestamp_seconds: None,
579            latest_included_proposal_timestamp_seconds: None,
580        }
581    }
582
583    fn observe(
584        &mut self,
585        request: &NnsProposalActivityRequest,
586        proposal: &NnsProposalRow,
587    ) -> Result<(), NnsProposalActivityError> {
588        validate_proposal_row(proposal, &mut self.proposal_ids)?;
589        let timestamp = proposal.proposal_timestamp_seconds;
590        if request
591            .from_proposal_timestamp_seconds
592            .is_some_and(|from| timestamp < from)
593        {
594            return increment_count(
595                &mut self.excluded_before_from_count,
596                "excluded_before_from_count",
597            );
598        }
599        if request
600            .until_proposal_timestamp_seconds
601            .is_some_and(|until| timestamp >= until)
602        {
603            return increment_count(
604                &mut self.excluded_at_or_after_until_count,
605                "excluded_at_or_after_until_count",
606            );
607        }
608
609        increment_count(&mut self.included_proposal_count, "included_proposal_count")?;
610        increment_count(
611            self.topic_counts.entry(proposal.topic).or_default(),
612            "topic_count",
613        )?;
614        increment_count(
615            self.status_counts.entry(proposal.status).or_default(),
616            "status_count",
617        )?;
618        increment_count(
619            self.reward_status_counts
620                .entry(proposal.reward_status)
621                .or_default(),
622            "reward_status_count",
623        )?;
624        let day_start = timestamp - (timestamp % SECONDS_PER_DAY);
625        increment_count(self.day_counts.entry(day_start).or_default(), "day_count")?;
626        self.earliest_included_proposal_timestamp_seconds = Some(
627            self.earliest_included_proposal_timestamp_seconds
628                .map_or(timestamp, |earliest| earliest.min(timestamp)),
629        );
630        self.latest_included_proposal_timestamp_seconds = Some(
631            self.latest_included_proposal_timestamp_seconds
632                .map_or(timestamp, |latest| latest.max(timestamp)),
633        );
634        Ok(())
635    }
636
637    fn into_report(
638        self,
639        request: &NnsProposalActivityRequest,
640        collection: &NnsProposalCollectionState,
641        collected_proposal_count: u64,
642        source: NnsGovernanceSourceProvenance,
643    ) -> NnsProposalActivityReport {
644        NnsProposalActivityReport {
645            schema_version: NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION,
646            network: collection.network().to_string(),
647            governance_canister_id: collection.governance_canister_id().to_string(),
648            source,
649            collection_started_at: collection.started_at().to_string(),
650            collection_updated_at: collection.updated_at().to_string(),
651            collection_page_count: collection.pages_fetched(),
652            collected_proposal_count,
653            point_in_time_guaranteed: false,
654            from_proposal_timestamp_seconds: request.from_proposal_timestamp_seconds,
655            until_proposal_timestamp_seconds: request.until_proposal_timestamp_seconds,
656            included_proposal_count: self.included_proposal_count,
657            excluded_before_from_count: self.excluded_before_from_count,
658            excluded_at_or_after_until_count: self.excluded_at_or_after_until_count,
659            earliest_included_proposal_timestamp_seconds: self
660                .earliest_included_proposal_timestamp_seconds,
661            latest_included_proposal_timestamp_seconds: self
662                .latest_included_proposal_timestamp_seconds,
663            topic_counts: self
664                .topic_counts
665                .into_iter()
666                .map(|(topic, proposal_count)| NnsProposalTopicCount {
667                    topic,
668
669                    proposal_count,
670                })
671                .collect(),
672            status_counts: self
673                .status_counts
674                .into_iter()
675                .map(|(status, proposal_count)| NnsProposalStatusCount {
676                    status,
677
678                    proposal_count,
679                })
680                .collect(),
681            reward_status_counts: self
682                .reward_status_counts
683                .into_iter()
684                .map(
685                    |(reward_status, proposal_count)| NnsProposalRewardStatusCount {
686                        reward_status,
687
688                        proposal_count,
689                    },
690                )
691                .collect(),
692            day_counts: self
693                .day_counts
694                .into_iter()
695                .map(
696                    |(day_start_timestamp_seconds, proposal_count)| NnsProposalDayCount {
697                        day_start_timestamp_seconds,
698                        proposal_count,
699                    },
700                )
701                .collect(),
702        }
703    }
704}
705
706pub(super) const fn validate_time_window(
707    request: &NnsProposalActivityRequest,
708) -> Result<(), NnsProposalActivityError> {
709    if let (Some(from), Some(until)) = (
710        request.from_proposal_timestamp_seconds,
711        request.until_proposal_timestamp_seconds,
712    ) && from >= until
713    {
714        return Err(NnsProposalActivityError::InvalidTimeWindow {
715            from_proposal_timestamp_seconds: from,
716            until_proposal_timestamp_seconds: until,
717        });
718    }
719    Ok(())
720}
721
722fn validate_proposal_row(
723    proposal: &NnsProposalRow,
724    proposal_ids: &mut HashSet<u64>,
725) -> Result<(), NnsProposalActivityError> {
726    let proposal_id = proposal
727        .proposal_id
728        .ok_or(NnsProposalActivityError::MissingProposalId)?;
729    if proposal_id == 0 {
730        return Err(NnsProposalActivityError::ZeroProposalId);
731    }
732    if !proposal_ids.insert(proposal_id) {
733        return Err(NnsProposalActivityError::DuplicateProposalId { proposal_id });
734    }
735    if proposal.proposal_timestamp_seconds == 0 {
736        return Err(NnsProposalActivityError::ZeroProposalTimestamp { proposal_id });
737    }
738
739    Ok(())
740}
741
742fn increment_count(count: &mut u64, field: &'static str) -> Result<(), NnsProposalActivityError> {
743    *count = count
744        .checked_add(1)
745        .ok_or(NnsProposalActivityError::AccountingOverflow { field })?;
746    Ok(())
747}
748
749#[cfg(test)]
750mod tests;