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::{
27        GetNeuronIndexRequestWire, GetNeuronIndexResultWire, GovernanceResult, NeuronIdWire,
28        NeuronInfoWire,
29    },
30};
31#[cfg(feature = "nns-host")]
32use crate::nns::LiveNnsSource;
33use crate::nns::governance::{
34    NnsGovernanceReportContext, NnsGovernanceRequest, NnsGovernanceSourceData,
35    validate_governance_request, validate_source_provenance,
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(NnsGovernanceReportContext::from_source(
99            &request.governance,
100            data.provenance,
101        )),
102        data.value.neurons,
103        data.value.next_start_neuron_id,
104        None,
105    ))
106}
107
108/// Build one exact NNS neuron detail from a caller-owned async source.
109pub async fn build_nns_neuron_info_report_with_source(
110    request: &NnsNeuronInfoRequest,
111    source: &dyn NnsNeuronSource,
112) -> Result<NnsNeuronInfoReport, NnsNeuronError> {
113    validate_governance_request(&request.governance)?;
114    let data = source
115        .fetch_neuron(&request.governance, request.neuron_id)
116        .await?;
117    validate_source_provenance(&request.governance.source, &data.provenance)?;
118    if data.value.neuron_id != request.neuron_id {
119        return Err(NnsNeuronError::InvalidResponse {
120            reason: format!(
121                "source returned neuron {}, expected {}",
122                data.value.neuron_id, request.neuron_id
123            ),
124        });
125    }
126    validate_neuron_rows(std::slice::from_ref(&data.value))?;
127    Ok(info_report_from_row(
128        request,
129        NnsNeuronReportProvenance::live(NnsGovernanceReportContext::from_source(
130            &request.governance,
131            data.provenance,
132        )),
133        data.value,
134    ))
135}
136
137///
138/// NnsNeuronSourceFuture
139///
140/// Boxed caller-runtime future returned by a neuron source.
141///
142
143pub type NnsNeuronSourceFuture<'a, T> =
144    Pin<Box<dyn Future<Output = Result<NnsGovernanceSourceData<T>, NnsNeuronError>> + Send + 'a>>;
145
146///
147/// NnsNeuronSource
148///
149/// Portable async capability for bounded neuron list and exact detail calls.
150///
151
152pub trait NnsNeuronSource: Send + Sync {
153    /// Fetch at most one bounded page from the public neuron index.
154    fn fetch_neuron_page<'a>(
155        &'a self,
156        request: &'a NnsGovernanceRequest,
157        exclusive_start_neuron_id: Option<u64>,
158        page_size: u32,
159    ) -> NnsNeuronSourceFuture<'a, NnsNeuronPage>;
160
161    /// Fetch one exact public limited neuron view.
162    fn fetch_neuron<'a>(
163        &'a self,
164        request: &'a NnsGovernanceRequest,
165        neuron_id: u64,
166    ) -> NnsNeuronSourceFuture<'a, NnsNeuronRow>;
167}
168
169///
170/// NnsNeuronReportProvenance
171///
172/// Shared live or cached provenance used while assembling neuron reports.
173///
174
175#[derive(Clone)]
176pub(super) struct NnsNeuronReportProvenance {
177    pub(super) context: NnsGovernanceReportContext,
178    pub(super) cache_path: Option<String>,
179}
180
181impl NnsNeuronReportProvenance {
182    const fn live(context: NnsGovernanceReportContext) -> Self {
183        Self {
184            context,
185            cache_path: None,
186        }
187    }
188}
189
190pub(super) fn list_report_from_rows(
191    request: &NnsNeuronListRequest,
192    provenance: NnsNeuronReportProvenance,
193    neurons: Vec<NnsNeuronRow>,
194    next_start_neuron_id: Option<u64>,
195    total_neuron_count: Option<usize>,
196) -> NnsNeuronListReport {
197    NnsNeuronListReport {
198        context: NnsGovernanceReportContext {
199            schema_version: NNS_NEURON_LIST_REPORT_SCHEMA_VERSION,
200            ..provenance.context
201        },
202        from_cache: provenance.cache_path.is_some(),
203        cache_path: provenance.cache_path,
204        requested_limit: request.limit,
205        exclusive_start_neuron_id: request.exclusive_start_neuron_id,
206        next_start_neuron_id,
207        total_neuron_count,
208        point_in_time_guaranteed: false,
209        returned_neuron_count: neurons.len(),
210        verbose: request.verbose,
211        neurons,
212    }
213}
214
215pub(super) fn info_report_from_row(
216    request: &NnsNeuronInfoRequest,
217    provenance: NnsNeuronReportProvenance,
218    neuron: NnsNeuronRow,
219) -> NnsNeuronInfoReport {
220    NnsNeuronInfoReport {
221        context: NnsGovernanceReportContext {
222            schema_version: NNS_NEURON_INFO_REPORT_SCHEMA_VERSION,
223            ..provenance.context
224        },
225        from_cache: provenance.cache_path.is_some(),
226        cache_path: provenance.cache_path,
227        verbose: request.verbose,
228        neuron,
229    }
230}
231
232pub(super) fn validate_page_size(page_size: u32) -> Result<(), NnsNeuronError> {
233    if (1..=NNS_NEURON_MAX_PAGE_SIZE).contains(&page_size) {
234        Ok(())
235    } else {
236        Err(NnsNeuronError::InvalidPageSize {
237            page_size,
238            max_page_size: NNS_NEURON_MAX_PAGE_SIZE,
239        })
240    }
241}
242
243pub(super) fn validate_neuron_rows(rows: &[NnsNeuronRow]) -> Result<(), NnsNeuronError> {
244    if rows
245        .windows(2)
246        .any(|pair| pair[0].neuron_id >= pair[1].neuron_id)
247    {
248        return Err(NnsNeuronError::InvalidResponse {
249            reason: "neuron ids are not strictly ascending and unique".to_string(),
250        });
251    }
252    Ok(())
253}
254
255pub(super) fn validate_neuron_page(
256    page: &NnsNeuronPage,
257    exclusive_start_neuron_id: Option<u64>,
258    page_size: u32,
259) -> Result<(), NnsNeuronError> {
260    if page.neurons.len() > page_size as usize {
261        return Err(NnsNeuronError::InvalidResponse {
262            reason: format!(
263                "source returned {} rows for page size {page_size}",
264                page.neurons.len()
265            ),
266        });
267    }
268    validate_neuron_rows(&page.neurons)?;
269    if let (Some(start), Some(first)) = (
270        exclusive_start_neuron_id,
271        page.neurons.first().map(|row| row.neuron_id),
272    ) && first <= start
273    {
274        return Err(NnsNeuronError::InvalidResponse {
275            reason: format!("first neuron id {first} is not greater than cursor {start}"),
276        });
277    }
278    let expected_next = (page.neurons.len() == page_size as usize)
279        .then(|| page.neurons.last().map(|row| row.neuron_id))
280        .flatten();
281    if page.next_start_neuron_id != expected_next {
282        return Err(NnsNeuronError::InvalidResponse {
283            reason: format!(
284                "next cursor {:?} does not match expected {:?}",
285                page.next_start_neuron_id, expected_next
286            ),
287        });
288    }
289    Ok(())
290}
291
292#[cfg(any(
293    feature = "nns-host",
294    all(feature = "canister", target_arch = "wasm32")
295))]
296pub(in crate::nns::neuron::report) fn governance_result<Response>(
297    result: impl GovernanceResult<Response>,
298) -> Result<Response, NnsNeuronError> {
299    result
300        .into_result()
301        .map_err(|error| NnsNeuronError::GovernanceResponse {
302            error_type: error.error_type,
303            message: error.error_message,
304        })
305}
306
307#[cfg(any(
308    feature = "nns-host",
309    all(feature = "canister", target_arch = "wasm32"),
310    test
311))]
312pub(in crate::nns::neuron::report) fn map_neuron_info_error(
313    error: NnsNeuronError,
314    neuron_id: u64,
315) -> NnsNeuronError {
316    match error {
317        NnsNeuronError::GovernanceResponse { error_type, .. }
318            if error_type == GOVERNANCE_ERROR_TYPE_NOT_FOUND =>
319        {
320            NnsNeuronError::NeuronNotFound { neuron_id }
321        }
322        error => error,
323    }
324}
325
326#[cfg(any(
327    feature = "nns-host",
328    all(feature = "canister", target_arch = "wasm32")
329))]
330pub(in crate::nns::neuron::report) fn neuron_row_from_wire(
331    wire: NeuronInfoWire,
332) -> Result<NnsNeuronRow, NnsNeuronError> {
333    let neuron_id = wire.id.ok_or(NnsNeuronError::MissingNeuronId)?.id;
334    Ok(NnsNeuronRow {
335        neuron_id,
336        state: wire.state,
337        visibility: wire.visibility,
338
339        neuron_type: wire.neuron_type,
340        stake_e8s: wire.stake_e8s,
341        staked_maturity_e8s_equivalent: wire.staked_maturity_e8s_equivalent,
342        dissolve_delay_seconds: wire.dissolve_delay_seconds,
343        age_seconds: wire.age_seconds,
344        created_timestamp_seconds: wire.created_timestamp_seconds,
345        retrieved_at_timestamp_seconds: wire.retrieved_at_timestamp_seconds,
346        voting_power: wire.voting_power,
347        deciding_voting_power: wire.deciding_voting_power,
348        potential_voting_power: wire.potential_voting_power,
349        voting_power_refreshed_timestamp_seconds: wire.voting_power_refreshed_timestamp_seconds,
350        joined_community_fund_timestamp_seconds: wire.joined_community_fund_timestamp_seconds,
351        eight_year_gang_bonus_base_e8s: wire.eight_year_gang_bonus_base_e8s,
352        known_neuron_data: wire.known_neuron_data.map(|known| NnsKnownNeuronData {
353            name: known.name,
354            description: known.description,
355            links: known.links.unwrap_or_default(),
356        }),
357        recent_ballots: wire
358            .recent_ballots
359            .into_iter()
360            .map(|ballot| NnsNeuronBallotRow {
361                proposal_id: ballot.proposal_id.map(|proposal| proposal.id),
362                vote: ballot.vote,
363            })
364            .collect(),
365    })
366}
367
368#[cfg(any(
369    feature = "nns-host",
370    all(feature = "canister", target_arch = "wasm32")
371))]
372fn neuron_page_request(
373    exclusive_start_neuron_id: Option<u64>,
374    page_size: u32,
375) -> GetNeuronIndexRequestWire {
376    GetNeuronIndexRequestWire {
377        exclusive_start_neuron_id: exclusive_start_neuron_id.map(|id| NeuronIdWire { id }),
378        page_size: Some(page_size),
379    }
380}
381
382#[cfg(any(
383    feature = "nns-host",
384    all(feature = "canister", target_arch = "wasm32")
385))]
386fn neuron_page_from_wire(
387    result: GetNeuronIndexResultWire,
388    page_size: u32,
389) -> Result<NnsNeuronPage, NnsNeuronError> {
390    let neurons = governance_result(result)?
391        .neurons
392        .into_iter()
393        .map(neuron_row_from_wire)
394        .collect::<Result<Vec<_>, _>>()?;
395    let next_start_neuron_id = (neurons.len() == page_size as usize)
396        .then(|| neurons.last().map(|row| row.neuron_id))
397        .flatten();
398    Ok(NnsNeuronPage {
399        neurons,
400        next_start_neuron_id,
401    })
402}
403
404#[cfg(test)]
405mod tests {
406    use super::{NnsNeuronError, map_neuron_info_error};
407
408    #[test]
409    fn neuron_info_maps_only_the_native_not_found_error() {
410        let not_found = map_neuron_info_error(
411            NnsNeuronError::GovernanceResponse {
412                error_type: 4,
413                message: "wording is not part of the contract".to_string(),
414            },
415            42,
416        );
417        assert!(matches!(
418            not_found,
419            NnsNeuronError::NeuronNotFound { neuron_id: 42 }
420        ));
421
422        let unrelated = map_neuron_info_error(
423            NnsNeuronError::GovernanceResponse {
424                error_type: 12,
425                message: "not found text must not override the code".to_string(),
426            },
427            42,
428        );
429        assert!(matches!(
430            unrelated,
431            NnsNeuronError::GovernanceResponse { error_type: 12, .. }
432        ));
433    }
434}