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