Skip to main content

ic_query/nns/neuron/report/source/
mod.rs

1//! Module: nns::neuron::report::source
2//!
3//! Responsibility: build NNS neuron reports from a portable async source.
4//! Does not own: CLI parsing, cache IO, transport internals, or text rendering.
5//! Boundary: native, canister, and custom sources converge before report assembly.
6
7#[cfg(all(feature = "canister", target_arch = "wasm32"))]
8mod canister;
9#[cfg(feature = "nns-host")]
10mod host;
11
12use super::{
13    NNS_NEURON_INFO_REPORT_SCHEMA_VERSION, NNS_NEURON_LIST_REPORT_SCHEMA_VERSION,
14    NNS_NEURON_MAX_PAGE_SIZE, NnsNeuronError,
15    model::{
16        NnsNeuronInfoReport, NnsNeuronInfoRequest, NnsNeuronListReport, NnsNeuronListRequest,
17        NnsNeuronRow,
18    },
19};
20#[cfg(any(
21    feature = "nns-host",
22    all(feature = "canister", target_arch = "wasm32")
23))]
24use super::{
25    model::{NnsKnownNeuronData, NnsNeuronBallotRow},
26    wire::{GovernanceResult, NeuronInfoWire},
27};
28#[cfg(feature = "nns-host")]
29use crate::nns::LiveNnsSource;
30use crate::nns::{
31    MAINNET_GOVERNANCE_CANISTER_ID,
32    governance::{
33        NnsGovernanceReportContext, NnsGovernanceRequest, NnsGovernanceSourceData,
34        NnsGovernanceSourceProvenance, validate_governance_request, validate_source_provenance,
35    },
36};
37use std::{future::Future, pin::Pin};
38
39#[cfg(any(
40    feature = "nns-host",
41    all(feature = "canister", target_arch = "wasm32"),
42    test
43))]
44const GOVERNANCE_ERROR_TYPE_NOT_FOUND: i32 = 4;
45
46///
47/// NnsNeuronPage
48///
49/// One canonically ascending page from the public Governance neuron index.
50///
51
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct NnsNeuronPage {
54    /// Public neuron rows returned by Governance.
55    pub neurons: Vec<NnsNeuronRow>,
56    /// Exclusive lower bound for a possible next page.
57    pub next_start_neuron_id: Option<u64>,
58}
59
60/// Build one bounded neuron-index page through the native replica adapter.
61#[cfg(feature = "nns-host")]
62pub async fn build_nns_neuron_list_report(
63    request: &NnsNeuronListRequest,
64) -> Result<NnsNeuronListReport, super::NnsNeuronHostError> {
65    Ok(build_nns_neuron_list_report_with_source(request, &LiveNnsSource::default()).await?)
66}
67
68/// Build one exact neuron detail through the native replica adapter.
69#[cfg(feature = "nns-host")]
70pub async fn build_nns_neuron_info_report(
71    request: &NnsNeuronInfoRequest,
72) -> Result<NnsNeuronInfoReport, super::NnsNeuronHostError> {
73    Ok(build_nns_neuron_info_report_with_source(request, &LiveNnsSource::default()).await?)
74}
75
76/// Build one bounded NNS neuron-index page from a caller-owned async source.
77pub async fn build_nns_neuron_list_report_with_source(
78    request: &NnsNeuronListRequest,
79    source: &dyn NnsNeuronSource,
80) -> Result<NnsNeuronListReport, NnsNeuronError> {
81    validate_governance_request(&request.governance)?;
82    validate_page_size(request.limit)?;
83    let data = source
84        .fetch_neuron_page(
85            &request.governance,
86            request.exclusive_start_neuron_id,
87            request.limit,
88        )
89        .await?;
90    validate_source_provenance(&request.governance.source, &data.provenance)?;
91    validate_neuron_page(
92        &data.value,
93        request.exclusive_start_neuron_id,
94        request.limit,
95    )?;
96    Ok(list_report_from_rows(
97        request,
98        NnsNeuronReportProvenance::live(report_context(&request.governance, data.provenance)),
99        data.value.neurons,
100        data.value.next_start_neuron_id,
101        None,
102    ))
103}
104
105/// Build one exact NNS neuron detail from a caller-owned async source.
106pub async fn build_nns_neuron_info_report_with_source(
107    request: &NnsNeuronInfoRequest,
108    source: &dyn NnsNeuronSource,
109) -> Result<NnsNeuronInfoReport, NnsNeuronError> {
110    validate_governance_request(&request.governance)?;
111    let data = source
112        .fetch_neuron(&request.governance, request.neuron_id)
113        .await?;
114    validate_source_provenance(&request.governance.source, &data.provenance)?;
115    if data.value.neuron_id != request.neuron_id {
116        return Err(NnsNeuronError::InvalidResponse {
117            reason: format!(
118                "source returned neuron {}, expected {}",
119                data.value.neuron_id, request.neuron_id
120            ),
121        });
122    }
123    validate_neuron_rows(std::slice::from_ref(&data.value))?;
124    Ok(info_report_from_row(
125        request,
126        NnsNeuronReportProvenance::live(report_context(&request.governance, data.provenance)),
127        data.value,
128    ))
129}
130
131///
132/// NnsNeuronSourceFuture
133///
134/// Boxed caller-runtime future returned by a neuron source.
135///
136
137pub type NnsNeuronSourceFuture<'a, T> =
138    Pin<Box<dyn Future<Output = Result<NnsGovernanceSourceData<T>, NnsNeuronError>> + Send + 'a>>;
139
140///
141/// NnsNeuronSource
142///
143/// Portable async capability for bounded neuron list and exact detail calls.
144///
145
146pub trait NnsNeuronSource: Send + Sync {
147    /// Fetch at most one bounded page from the public neuron index.
148    fn fetch_neuron_page<'a>(
149        &'a self,
150        request: &'a NnsGovernanceRequest,
151        exclusive_start_neuron_id: Option<u64>,
152        page_size: u32,
153    ) -> NnsNeuronSourceFuture<'a, NnsNeuronPage>;
154
155    /// Fetch one exact public limited neuron view.
156    fn fetch_neuron<'a>(
157        &'a self,
158        request: &'a NnsGovernanceRequest,
159        neuron_id: u64,
160    ) -> NnsNeuronSourceFuture<'a, NnsNeuronRow>;
161}
162
163///
164/// NnsNeuronReportProvenance
165///
166/// Shared live or cached provenance used while assembling neuron reports.
167///
168
169#[derive(Clone)]
170pub(super) struct NnsNeuronReportProvenance {
171    pub(super) context: NnsGovernanceReportContext,
172    pub(super) cache_path: Option<String>,
173}
174
175impl NnsNeuronReportProvenance {
176    const fn live(context: NnsGovernanceReportContext) -> Self {
177        Self {
178            context,
179            cache_path: None,
180        }
181    }
182}
183
184pub(super) fn list_report_from_rows(
185    request: &NnsNeuronListRequest,
186    provenance: NnsNeuronReportProvenance,
187    neurons: Vec<NnsNeuronRow>,
188    next_start_neuron_id: Option<u64>,
189    total_neuron_count: Option<usize>,
190) -> NnsNeuronListReport {
191    NnsNeuronListReport {
192        context: NnsGovernanceReportContext {
193            schema_version: NNS_NEURON_LIST_REPORT_SCHEMA_VERSION,
194            ..provenance.context
195        },
196        from_cache: provenance.cache_path.is_some(),
197        cache_path: provenance.cache_path,
198        requested_limit: request.limit,
199        exclusive_start_neuron_id: request.exclusive_start_neuron_id,
200        next_start_neuron_id,
201        total_neuron_count,
202        point_in_time_guaranteed: false,
203        returned_neuron_count: neurons.len(),
204        verbose: request.verbose,
205        neurons,
206    }
207}
208
209pub(super) fn info_report_from_row(
210    request: &NnsNeuronInfoRequest,
211    provenance: NnsNeuronReportProvenance,
212    neuron: NnsNeuronRow,
213) -> NnsNeuronInfoReport {
214    NnsNeuronInfoReport {
215        context: NnsGovernanceReportContext {
216            schema_version: NNS_NEURON_INFO_REPORT_SCHEMA_VERSION,
217            ..provenance.context
218        },
219        from_cache: provenance.cache_path.is_some(),
220        cache_path: provenance.cache_path,
221        verbose: request.verbose,
222        neuron,
223    }
224}
225
226pub(super) fn validate_page_size(page_size: u32) -> Result<(), NnsNeuronError> {
227    if (1..=NNS_NEURON_MAX_PAGE_SIZE).contains(&page_size) {
228        Ok(())
229    } else {
230        Err(NnsNeuronError::InvalidPageSize {
231            page_size,
232            max_page_size: NNS_NEURON_MAX_PAGE_SIZE,
233        })
234    }
235}
236
237pub(super) fn validate_neuron_rows(rows: &[NnsNeuronRow]) -> Result<(), NnsNeuronError> {
238    if rows
239        .windows(2)
240        .any(|pair| pair[0].neuron_id >= pair[1].neuron_id)
241    {
242        return Err(NnsNeuronError::InvalidResponse {
243            reason: "neuron ids are not strictly ascending and unique".to_string(),
244        });
245    }
246    Ok(())
247}
248
249pub(super) fn validate_neuron_page(
250    page: &NnsNeuronPage,
251    exclusive_start_neuron_id: Option<u64>,
252    page_size: u32,
253) -> Result<(), NnsNeuronError> {
254    if page.neurons.len() > page_size as usize {
255        return Err(NnsNeuronError::InvalidResponse {
256            reason: format!(
257                "source returned {} rows for page size {page_size}",
258                page.neurons.len()
259            ),
260        });
261    }
262    validate_neuron_rows(&page.neurons)?;
263    if let (Some(start), Some(first)) = (
264        exclusive_start_neuron_id,
265        page.neurons.first().map(|row| row.neuron_id),
266    ) && first <= start
267    {
268        return Err(NnsNeuronError::InvalidResponse {
269            reason: format!("first neuron id {first} is not greater than cursor {start}"),
270        });
271    }
272    let expected_next = (page.neurons.len() == page_size as usize)
273        .then(|| page.neurons.last().map(|row| row.neuron_id))
274        .flatten();
275    if page.next_start_neuron_id != expected_next {
276        return Err(NnsNeuronError::InvalidResponse {
277            reason: format!(
278                "next cursor {:?} does not match expected {:?}",
279                page.next_start_neuron_id, expected_next
280            ),
281        });
282    }
283    Ok(())
284}
285
286fn report_context(
287    request: &NnsGovernanceRequest,
288    source: NnsGovernanceSourceProvenance,
289) -> NnsGovernanceReportContext {
290    NnsGovernanceReportContext {
291        schema_version: 1,
292        network: request.network.clone(),
293        governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
294        fetched_at: request.fetched_at.clone(),
295        source,
296    }
297}
298
299#[cfg(any(
300    feature = "nns-host",
301    all(feature = "canister", target_arch = "wasm32")
302))]
303pub(in crate::nns::neuron::report) fn governance_result<Response>(
304    result: impl GovernanceResult<Response>,
305) -> Result<Response, NnsNeuronError> {
306    result
307        .into_result()
308        .map_err(|error| NnsNeuronError::GovernanceResponse {
309            error_type: error.error_type,
310            message: error.error_message,
311        })
312}
313
314#[cfg(any(
315    feature = "nns-host",
316    all(feature = "canister", target_arch = "wasm32"),
317    test
318))]
319pub(in crate::nns::neuron::report) fn map_neuron_info_error(
320    error: NnsNeuronError,
321    neuron_id: u64,
322) -> NnsNeuronError {
323    match error {
324        NnsNeuronError::GovernanceResponse { error_type, .. }
325            if error_type == GOVERNANCE_ERROR_TYPE_NOT_FOUND =>
326        {
327            NnsNeuronError::NeuronNotFound { neuron_id }
328        }
329        error => error,
330    }
331}
332
333#[cfg(any(
334    feature = "nns-host",
335    all(feature = "canister", target_arch = "wasm32")
336))]
337pub(in crate::nns::neuron::report) fn neuron_row_from_wire(
338    wire: NeuronInfoWire,
339) -> Result<NnsNeuronRow, NnsNeuronError> {
340    let neuron_id = wire.id.ok_or(NnsNeuronError::MissingNeuronId)?.id;
341    Ok(NnsNeuronRow {
342        neuron_id,
343        state: wire.state,
344        visibility: wire.visibility,
345
346        neuron_type: wire.neuron_type,
347        stake_e8s: wire.stake_e8s,
348        staked_maturity_e8s_equivalent: wire.staked_maturity_e8s_equivalent,
349        dissolve_delay_seconds: wire.dissolve_delay_seconds,
350        age_seconds: wire.age_seconds,
351        created_timestamp_seconds: wire.created_timestamp_seconds,
352        retrieved_at_timestamp_seconds: wire.retrieved_at_timestamp_seconds,
353        voting_power: wire.voting_power,
354        deciding_voting_power: wire.deciding_voting_power,
355        potential_voting_power: wire.potential_voting_power,
356        voting_power_refreshed_timestamp_seconds: wire.voting_power_refreshed_timestamp_seconds,
357        joined_community_fund_timestamp_seconds: wire.joined_community_fund_timestamp_seconds,
358        eight_year_gang_bonus_base_e8s: wire.eight_year_gang_bonus_base_e8s,
359        known_neuron_data: wire.known_neuron_data.map(|known| NnsKnownNeuronData {
360            name: known.name,
361            description: known.description,
362            links: known.links.unwrap_or_default(),
363        }),
364        recent_ballots: wire
365            .recent_ballots
366            .into_iter()
367            .map(|ballot| NnsNeuronBallotRow {
368                proposal_id: ballot.proposal_id.map(|proposal| proposal.id),
369                vote: ballot.vote,
370            })
371            .collect(),
372    })
373}
374
375#[cfg(test)]
376mod tests {
377    use super::{NnsNeuronError, map_neuron_info_error};
378
379    #[test]
380    fn neuron_info_maps_only_the_native_not_found_error() {
381        let not_found = map_neuron_info_error(
382            NnsNeuronError::GovernanceResponse {
383                error_type: 4,
384                message: "wording is not part of the contract".to_string(),
385            },
386            42,
387        );
388        assert!(matches!(
389            not_found,
390            NnsNeuronError::NeuronNotFound { neuron_id: 42 }
391        ));
392
393        let unrelated = map_neuron_info_error(
394            NnsNeuronError::GovernanceResponse {
395                error_type: 12,
396                message: "not found text must not override the code".to_string(),
397            },
398            42,
399        );
400        assert!(matches!(
401            unrelated,
402            NnsNeuronError::GovernanceResponse { error_type: 12, .. }
403        ));
404    }
405}