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