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