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 #[serde(flatten)]
103 pub context: crate::nns::governance::NnsGovernanceAnalyticsContext,
104 pub collected_proposal_count: u64,
106 pub from_proposal_timestamp_seconds: Option<u64>,
108 pub until_proposal_timestamp_seconds: Option<u64>,
110 pub included_proposal_count: u64,
112 pub excluded_before_from_count: u64,
114 pub excluded_at_or_after_until_count: u64,
116 pub earliest_included_proposal_timestamp_seconds: Option<u64>,
118 pub latest_included_proposal_timestamp_seconds: Option<u64>,
120 pub topic_counts: Vec<NnsProposalTopicCount>,
122 pub status_counts: Vec<NnsProposalStatusCount>,
124 pub reward_status_counts: Vec<NnsProposalRewardStatusCount>,
126 pub day_counts: Vec<NnsProposalDayCount>,
128}
129
130#[derive(Debug, Eq, PartialEq, ThisError)]
137#[error("invalid NNS proposal activity report: {reason}")]
138pub struct NnsProposalActivityValidationError {
139 pub reason: String,
141}
142
143#[derive(Debug, ThisError)]
150pub enum NnsProposalActivityError {
151 #[error("invalid NNS proposal collection state for activity projection: {reason}")]
153 InvalidCollectionState {
154 reason: String,
156 },
157
158 #[error("NNS proposal activity requires a complete collection; state is {status}")]
160 CollectionNotComplete {
161 status: NnsGovernanceCollectionStatus,
163 },
164
165 #[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 from_proposal_timestamp_seconds: u64,
172 until_proposal_timestamp_seconds: u64,
174 },
175
176 #[error(
178 "NNS proposal activity received {actual} rows; complete collection accounts for {expected}"
179 )]
180 ProposalCountMismatch {
181 expected: u64,
183 actual: u64,
185 },
186
187 #[error("NNS proposal activity received a row without a proposal id")]
189 MissingProposalId,
190
191 #[error("NNS proposal activity received proposal id zero")]
193 ZeroProposalId,
194
195 #[error("NNS proposal activity received duplicate proposal id {proposal_id}")]
197 DuplicateProposalId {
198 proposal_id: u64,
200 },
201
202 #[error("NNS proposal {proposal_id} has proposal timestamp zero")]
204 ZeroProposalTimestamp {
205 proposal_id: u64,
207 },
208
209 #[error("NNS proposal activity accounting overflow while updating {field}")]
211 AccountingOverflow {
212 field: &'static str,
214 },
215}
216
217pub 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
450pub 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 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;