1use 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
15pub const NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION: u32 = 1;
17
18const SECONDS_PER_DAY: u64 = 86_400;
19
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
27pub struct NnsProposalActivityRequest {
28 pub from_proposal_timestamp_seconds: Option<u64>,
30 pub until_proposal_timestamp_seconds: Option<u64>,
32}
33
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
41#[serde(deny_unknown_fields)]
42pub struct NnsProposalTopicCount {
43 pub topic: i32,
45 pub proposal_count: u64,
47}
48
49#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
56#[serde(deny_unknown_fields)]
57pub struct NnsProposalStatusCount {
58 pub status: i32,
60 pub proposal_count: u64,
62}
63
64#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
71#[serde(deny_unknown_fields)]
72pub struct NnsProposalRewardStatusCount {
73 pub reward_status: i32,
75 pub proposal_count: u64,
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
86pub struct NnsProposalDayCount {
87 pub day_start_timestamp_seconds: u64,
89 pub proposal_count: u64,
91}
92
93#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
100pub struct NnsProposalActivityReport {
101 pub schema_version: u32,
103 pub network: String,
105 pub governance_canister_id: String,
107 pub source: NnsGovernanceSourceProvenance,
109 pub collection_started_at: String,
111 pub collection_updated_at: String,
113 pub collection_page_count: u32,
115 pub collected_proposal_count: u64,
117 pub point_in_time_guaranteed: bool,
119 pub from_proposal_timestamp_seconds: Option<u64>,
121 pub until_proposal_timestamp_seconds: Option<u64>,
123 pub included_proposal_count: u64,
125 pub excluded_before_from_count: u64,
127 pub excluded_at_or_after_until_count: u64,
129 pub earliest_included_proposal_timestamp_seconds: Option<u64>,
131 pub latest_included_proposal_timestamp_seconds: Option<u64>,
133 pub topic_counts: Vec<NnsProposalTopicCount>,
135 pub status_counts: Vec<NnsProposalStatusCount>,
137 pub reward_status_counts: Vec<NnsProposalRewardStatusCount>,
139 pub day_counts: Vec<NnsProposalDayCount>,
141}
142
143#[derive(Debug, Eq, PartialEq, ThisError)]
150#[error("invalid NNS proposal activity report: {reason}")]
151pub struct NnsProposalActivityValidationError {
152 pub reason: String,
154}
155
156#[derive(Debug, ThisError)]
163pub enum NnsProposalActivityError {
164 #[error("invalid NNS proposal collection state for activity projection: {reason}")]
166 InvalidCollectionState {
167 reason: String,
169 },
170
171 #[error("NNS proposal activity requires a complete collection; state is {status}")]
173 CollectionNotComplete {
174 status: NnsGovernanceCollectionStatus,
176 },
177
178 #[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 from_proposal_timestamp_seconds: u64,
185 until_proposal_timestamp_seconds: u64,
187 },
188
189 #[error(
191 "NNS proposal activity received {actual} rows; complete collection accounts for {expected}"
192 )]
193 ProposalCountMismatch {
194 expected: u64,
196 actual: u64,
198 },
199
200 #[error("NNS proposal activity received a row without a proposal id")]
202 MissingProposalId,
203
204 #[error("NNS proposal activity received proposal id zero")]
206 ZeroProposalId,
207
208 #[error("NNS proposal activity received duplicate proposal id {proposal_id}")]
210 DuplicateProposalId {
211 proposal_id: u64,
213 },
214
215 #[error("NNS proposal {proposal_id} has proposal timestamp zero")]
217 ZeroProposalTimestamp {
218 proposal_id: u64,
220 },
221
222 #[error("NNS proposal activity accounting overflow while updating {field}")]
224 AccountingOverflow {
225 field: &'static str,
227 },
228}
229
230pub 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
471pub 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 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;