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