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