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