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