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};
37
38#[cfg(any(
39    feature = "nns-host",
40    all(feature = "canister", target_arch = "wasm32"),
41    test
42))]
43const GOVERNANCE_ERROR_TYPE_NOT_FOUND: i32 = 4;
44
45///
46/// NnsNeuronPage
47///
48/// One canonically ascending page from the public Governance neuron index.
49///
50
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct NnsNeuronPage {
53    /// Public neuron rows returned by Governance.
54    pub neurons: Vec<NnsNeuronRow>,
55    /// Exclusive lower bound for a possible next page.
56    pub next_start_neuron_id: Option<u64>,
57}
58
59/// Build one bounded neuron-index page through the native replica adapter.
60#[cfg(feature = "nns-host")]
61pub async fn build_nns_neuron_list_report(
62    request: &NnsNeuronListRequest,
63) -> Result<NnsNeuronListReport, super::NnsNeuronHostError> {
64    Ok(build_nns_neuron_list_report_with_source(request, &LiveNnsSource::default()).await?)
65}
66
67/// Build one exact neuron detail through the native replica adapter.
68#[cfg(feature = "nns-host")]
69pub async fn build_nns_neuron_info_report(
70    request: &NnsNeuronInfoRequest,
71) -> Result<NnsNeuronInfoReport, super::NnsNeuronHostError> {
72    Ok(build_nns_neuron_info_report_with_source(request, &LiveNnsSource::default()).await?)
73}
74
75/// Build one bounded NNS neuron-index page from a caller-owned async source.
76pub async fn build_nns_neuron_list_report_with_source(
77    request: &NnsNeuronListRequest,
78    source: &dyn NnsNeuronSource,
79) -> Result<NnsNeuronListReport, NnsNeuronError> {
80    validate_governance_request(&request.governance)?;
81    validate_page_size(request.limit)?;
82    let data = source
83        .fetch_neuron_page(
84            &request.governance,
85            request.exclusive_start_neuron_id,
86            request.limit,
87        )
88        .await?;
89    validate_source_provenance(&request.governance.source, &data.provenance)?;
90    validate_neuron_page(
91        &data.value,
92        request.exclusive_start_neuron_id,
93        request.limit,
94    )?;
95    Ok(list_report_from_rows(
96        request,
97        NnsNeuronReportProvenance::live(NnsGovernanceReportContext::from_source(
98            &request.governance,
99            data.provenance,
100        )),
101        data.value.neurons,
102        data.value.next_start_neuron_id,
103        None,
104    ))
105}
106
107/// Build one exact NNS neuron detail from a caller-owned async source.
108pub async fn build_nns_neuron_info_report_with_source(
109    request: &NnsNeuronInfoRequest,
110    source: &dyn NnsNeuronSource,
111) -> Result<NnsNeuronInfoReport, NnsNeuronError> {
112    validate_governance_request(&request.governance)?;
113    let data = source
114        .fetch_neuron(&request.governance, request.neuron_id)
115        .await?;
116    validate_source_provenance(&request.governance.source, &data.provenance)?;
117    if data.value.neuron_id != request.neuron_id {
118        return Err(NnsNeuronError::InvalidResponse {
119            reason: format!(
120                "source returned neuron {}, expected {}",
121                data.value.neuron_id, request.neuron_id
122            ),
123        });
124    }
125    validate_neuron_rows(std::slice::from_ref(&data.value))?;
126    Ok(info_report_from_row(
127        request,
128        NnsNeuronReportProvenance::live(NnsGovernanceReportContext::from_source(
129            &request.governance,
130            data.provenance,
131        )),
132        data.value,
133    ))
134}
135
136///
137/// NnsNeuronSourceFuture
138///
139/// Boxed caller-runtime future returned by a neuron source.
140///
141
142pub type NnsNeuronSourceFuture<'a, T> =
143    crate::report::SourceFuture<'a, Result<NnsGovernanceSourceData<T>, NnsNeuronError>>;
144
145///
146/// NnsNeuronSource
147///
148/// Portable async capability for bounded neuron list and exact detail calls.
149///
150
151pub trait NnsNeuronSource: Send + Sync {
152    /// Fetch at most one bounded page from the public neuron index.
153    fn fetch_neuron_page<'a>(
154        &'a self,
155        request: &'a NnsGovernanceRequest,
156        exclusive_start_neuron_id: Option<u64>,
157        page_size: u32,
158    ) -> NnsNeuronSourceFuture<'a, NnsNeuronPage>;
159
160    /// Fetch one exact public limited neuron view.
161    fn fetch_neuron<'a>(
162        &'a self,
163        request: &'a NnsGovernanceRequest,
164        neuron_id: u64,
165    ) -> NnsNeuronSourceFuture<'a, NnsNeuronRow>;
166}
167
168///
169/// NnsNeuronReportProvenance
170///
171/// Shared live or cached provenance used while assembling neuron reports.
172///
173
174#[derive(Clone)]
175pub(super) struct NnsNeuronReportProvenance {
176    pub(super) context: NnsGovernanceReportContext,
177    pub(super) cache_path: Option<String>,
178}
179
180impl NnsNeuronReportProvenance {
181    const fn live(context: NnsGovernanceReportContext) -> Self {
182        Self {
183            context,
184            cache_path: None,
185        }
186    }
187}
188
189pub(super) fn list_report_from_rows(
190    request: &NnsNeuronListRequest,
191    provenance: NnsNeuronReportProvenance,
192    neurons: Vec<NnsNeuronRow>,
193    next_start_neuron_id: Option<u64>,
194    total_neuron_count: Option<usize>,
195) -> NnsNeuronListReport {
196    NnsNeuronListReport {
197        context: NnsGovernanceReportContext {
198            schema_version: NNS_NEURON_LIST_REPORT_SCHEMA_VERSION,
199            ..provenance.context
200        },
201        from_cache: provenance.cache_path.is_some(),
202        cache_path: provenance.cache_path,
203        requested_limit: request.limit,
204        exclusive_start_neuron_id: request.exclusive_start_neuron_id,
205        next_start_neuron_id,
206        total_neuron_count,
207        point_in_time_guaranteed: false,
208        returned_neuron_count: neurons.len(),
209        verbose: request.verbose,
210        neurons,
211    }
212}
213
214pub(super) fn info_report_from_row(
215    request: &NnsNeuronInfoRequest,
216    provenance: NnsNeuronReportProvenance,
217    neuron: NnsNeuronRow,
218) -> NnsNeuronInfoReport {
219    NnsNeuronInfoReport {
220        context: NnsGovernanceReportContext {
221            schema_version: NNS_NEURON_INFO_REPORT_SCHEMA_VERSION,
222            ..provenance.context
223        },
224        from_cache: provenance.cache_path.is_some(),
225        cache_path: provenance.cache_path,
226        verbose: request.verbose,
227        neuron,
228    }
229}
230
231pub(super) fn validate_page_size(page_size: u32) -> Result<(), NnsNeuronError> {
232    if (1..=NNS_NEURON_MAX_PAGE_SIZE).contains(&page_size) {
233        Ok(())
234    } else {
235        Err(NnsNeuronError::InvalidPageSize {
236            page_size,
237            max_page_size: NNS_NEURON_MAX_PAGE_SIZE,
238        })
239    }
240}
241
242pub(super) fn validate_neuron_rows(rows: &[NnsNeuronRow]) -> Result<(), NnsNeuronError> {
243    if rows
244        .windows(2)
245        .any(|pair| pair[0].neuron_id >= pair[1].neuron_id)
246    {
247        return Err(NnsNeuronError::InvalidResponse {
248            reason: "neuron ids are not strictly ascending and unique".to_string(),
249        });
250    }
251    Ok(())
252}
253
254pub(super) fn validate_neuron_page(
255    page: &NnsNeuronPage,
256    exclusive_start_neuron_id: Option<u64>,
257    page_size: u32,
258) -> Result<(), NnsNeuronError> {
259    if page.neurons.len() > page_size as usize {
260        return Err(NnsNeuronError::InvalidResponse {
261            reason: format!(
262                "source returned {} rows for page size {page_size}",
263                page.neurons.len()
264            ),
265        });
266    }
267    validate_neuron_rows(&page.neurons)?;
268    if let (Some(start), Some(first)) = (
269        exclusive_start_neuron_id,
270        page.neurons.first().map(|row| row.neuron_id),
271    ) && first <= start
272    {
273        return Err(NnsNeuronError::InvalidResponse {
274            reason: format!("first neuron id {first} is not greater than cursor {start}"),
275        });
276    }
277    let expected_next = (page.neurons.len() == page_size as usize)
278        .then(|| page.neurons.last().map(|row| row.neuron_id))
279        .flatten();
280    if page.next_start_neuron_id != expected_next {
281        return Err(NnsNeuronError::InvalidResponse {
282            reason: format!(
283                "next cursor {:?} does not match expected {:?}",
284                page.next_start_neuron_id, expected_next
285            ),
286        });
287    }
288    Ok(())
289}
290
291#[cfg(any(
292    feature = "nns-host",
293    all(feature = "canister", target_arch = "wasm32")
294))]
295pub(in crate::nns::neuron::report) fn governance_result<Response>(
296    result: impl GovernanceResult<Response>,
297) -> Result<Response, NnsNeuronError> {
298    result
299        .into_result()
300        .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| NeuronIdWire { 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}