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, NnsNeuronVote},
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};
29use crate::nns::{
30    MAINNET_GOVERNANCE_CANISTER_ID,
31    governance::{
32        NnsGovernanceReportContext, NnsGovernanceRequest, NnsGovernanceSourceData,
33        NnsGovernanceSourceProvenance, validate_governance_request, validate_source_provenance,
34    },
35};
36#[cfg(feature = "nns-host")]
37use crate::{nns::LiveNnsSource, runtime::block_on_current_thread};
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 fn build_nns_neuron_list_report(
64    request: &NnsNeuronListRequest,
65) -> Result<NnsNeuronListReport, super::NnsNeuronHostError> {
66    Ok(block_on_current_thread(
67        build_nns_neuron_list_report_with_source(request, &LiveNnsSource),
68    )??)
69}
70
71/// Build one exact neuron detail through the native replica adapter.
72#[cfg(feature = "nns-host")]
73pub fn build_nns_neuron_info_report(
74    request: &NnsNeuronInfoRequest,
75) -> Result<NnsNeuronInfoReport, super::NnsNeuronHostError> {
76    Ok(block_on_current_thread(
77        build_nns_neuron_info_report_with_source(request, &LiveNnsSource),
78    )??)
79}
80
81/// Build one bounded NNS neuron-index page from a caller-owned async source.
82pub async fn build_nns_neuron_list_report_with_source(
83    request: &NnsNeuronListRequest,
84    source: &dyn NnsNeuronSource,
85) -> Result<NnsNeuronListReport, NnsNeuronError> {
86    validate_governance_request(&request.governance)?;
87    validate_page_size(request.limit)?;
88    let data = source
89        .fetch_neuron_page(
90            &request.governance,
91            request.exclusive_start_neuron_id,
92            request.limit,
93        )
94        .await?;
95    validate_source_provenance(&request.governance.source, &data.provenance)?;
96    validate_neuron_page(
97        &data.value,
98        request.exclusive_start_neuron_id,
99        request.limit,
100    )?;
101    Ok(list_report_from_rows(
102        request,
103        NnsNeuronReportProvenance::live(report_context(&request.governance, data.provenance)),
104        data.value.neurons,
105        data.value.next_start_neuron_id,
106        None,
107    ))
108}
109
110/// Build one exact NNS neuron detail from a caller-owned async source.
111pub async fn build_nns_neuron_info_report_with_source(
112    request: &NnsNeuronInfoRequest,
113    source: &dyn NnsNeuronSource,
114) -> Result<NnsNeuronInfoReport, NnsNeuronError> {
115    validate_governance_request(&request.governance)?;
116    let data = source
117        .fetch_neuron(&request.governance, request.neuron_id)
118        .await?;
119    validate_source_provenance(&request.governance.source, &data.provenance)?;
120    if data.value.neuron_id != request.neuron_id {
121        return Err(NnsNeuronError::InvalidResponse {
122            reason: format!(
123                "source returned neuron {}, expected {}",
124                data.value.neuron_id, request.neuron_id
125            ),
126        });
127    }
128    validate_neuron_rows(std::slice::from_ref(&data.value))?;
129    Ok(info_report_from_row(
130        request,
131        NnsNeuronReportProvenance::live(report_context(&request.governance, data.provenance)),
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    Pin<Box<dyn Future<Output = Result<NnsGovernanceSourceData<T>, NnsNeuronError>> + Send + 'a>>;
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    pub(super) from_cache: bool,
179}
180
181impl NnsNeuronReportProvenance {
182    const fn live(context: NnsGovernanceReportContext) -> Self {
183        Self {
184            context,
185            cache_path: None,
186            from_cache: false,
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        cache_path: provenance.cache_path,
204        from_cache: provenance.from_cache,
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        cache_path: provenance.cache_path,
227        from_cache: provenance.from_cache,
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    for row in rows {
246        if row.state_text != NnsNeuronState::from_code(row.state) {
247            return Err(NnsNeuronError::InvalidResponse {
248                reason: format!(
249                    "neuron {} state classification {} does not match raw code {}",
250                    row.neuron_id, row.state_text, row.state
251                ),
252            });
253        }
254        if row.visibility_text != NnsNeuronVisibility::from_code(row.visibility) {
255            return Err(NnsNeuronError::InvalidResponse {
256                reason: format!(
257                    "neuron {} visibility classification {} does not match raw code {:?}",
258                    row.neuron_id, row.visibility_text, row.visibility
259                ),
260            });
261        }
262        if row.neuron_type_text != NnsNeuronType::from_code(row.neuron_type) {
263            return Err(NnsNeuronError::InvalidResponse {
264                reason: format!(
265                    "neuron {} type classification {} does not match raw code {:?}",
266                    row.neuron_id, row.neuron_type_text, row.neuron_type
267                ),
268            });
269        }
270        if let Some(ballot) = row
271            .recent_ballots
272            .iter()
273            .find(|ballot| ballot.vote_text != NnsNeuronVote::from_code(ballot.vote))
274        {
275            return Err(NnsNeuronError::InvalidResponse {
276                reason: format!(
277                    "neuron {} ballot vote classification {} does not match raw code {}",
278                    row.neuron_id, ballot.vote_text, ballot.vote
279                ),
280            });
281        }
282    }
283    if rows
284        .windows(2)
285        .any(|pair| pair[0].neuron_id >= pair[1].neuron_id)
286    {
287        return Err(NnsNeuronError::InvalidResponse {
288            reason: "neuron ids are not strictly ascending and unique".to_string(),
289        });
290    }
291    Ok(())
292}
293
294pub(super) fn validate_neuron_page(
295    page: &NnsNeuronPage,
296    exclusive_start_neuron_id: Option<u64>,
297    page_size: u32,
298) -> Result<(), NnsNeuronError> {
299    if page.neurons.len() > page_size as usize {
300        return Err(NnsNeuronError::InvalidResponse {
301            reason: format!(
302                "source returned {} rows for page size {page_size}",
303                page.neurons.len()
304            ),
305        });
306    }
307    validate_neuron_rows(&page.neurons)?;
308    if let (Some(start), Some(first)) = (
309        exclusive_start_neuron_id,
310        page.neurons.first().map(|row| row.neuron_id),
311    ) && first <= start
312    {
313        return Err(NnsNeuronError::InvalidResponse {
314            reason: format!("first neuron id {first} is not greater than cursor {start}"),
315        });
316    }
317    let expected_next = (page.neurons.len() == page_size as usize)
318        .then(|| page.neurons.last().map(|row| row.neuron_id))
319        .flatten();
320    if page.next_start_neuron_id != expected_next {
321        return Err(NnsNeuronError::InvalidResponse {
322            reason: format!(
323                "next cursor {:?} does not match expected {:?}",
324                page.next_start_neuron_id, expected_next
325            ),
326        });
327    }
328    Ok(())
329}
330
331fn report_context(
332    request: &NnsGovernanceRequest,
333    source: NnsGovernanceSourceProvenance,
334) -> NnsGovernanceReportContext {
335    NnsGovernanceReportContext {
336        schema_version: 1,
337        network: request.network.clone(),
338        governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
339        fetched_at: request.fetched_at.clone(),
340        source,
341    }
342}
343
344#[cfg(any(
345    feature = "nns-host",
346    all(feature = "canister", target_arch = "wasm32")
347))]
348pub(in crate::nns::neuron::report) fn governance_result<Response>(
349    result: impl GovernanceResult<Response>,
350) -> Result<Response, NnsNeuronError> {
351    result
352        .into_result()
353        .map_err(|error| NnsNeuronError::GovernanceResponse {
354            error_type: error.error_type,
355            message: error.error_message,
356        })
357}
358
359#[cfg(any(
360    feature = "nns-host",
361    all(feature = "canister", target_arch = "wasm32"),
362    test
363))]
364pub(in crate::nns::neuron::report) fn map_neuron_info_error(
365    error: NnsNeuronError,
366    neuron_id: u64,
367) -> NnsNeuronError {
368    match error {
369        NnsNeuronError::GovernanceResponse { error_type, .. }
370            if error_type == GOVERNANCE_ERROR_TYPE_NOT_FOUND =>
371        {
372            NnsNeuronError::NeuronNotFound { neuron_id }
373        }
374        error => error,
375    }
376}
377
378#[cfg(any(
379    feature = "nns-host",
380    all(feature = "canister", target_arch = "wasm32")
381))]
382pub(in crate::nns::neuron::report) fn neuron_row_from_wire(
383    wire: NeuronInfoWire,
384) -> Result<NnsNeuronRow, NnsNeuronError> {
385    let neuron_id = wire.id.ok_or(NnsNeuronError::MissingNeuronId)?.id;
386    Ok(NnsNeuronRow {
387        neuron_id,
388        state: wire.state,
389        state_text: NnsNeuronState::from_code(wire.state),
390        visibility: wire.visibility,
391        visibility_text: NnsNeuronVisibility::from_code(wire.visibility),
392        neuron_type: wire.neuron_type,
393        neuron_type_text: NnsNeuronType::from_code(wire.neuron_type),
394        stake_e8s: wire.stake_e8s,
395        staked_maturity_e8s_equivalent: wire.staked_maturity_e8s_equivalent,
396        dissolve_delay_seconds: wire.dissolve_delay_seconds,
397        age_seconds: wire.age_seconds,
398        created_timestamp_seconds: wire.created_timestamp_seconds,
399        retrieved_at_timestamp_seconds: wire.retrieved_at_timestamp_seconds,
400        voting_power: wire.voting_power,
401        deciding_voting_power: wire.deciding_voting_power,
402        potential_voting_power: wire.potential_voting_power,
403        voting_power_refreshed_timestamp_seconds: wire.voting_power_refreshed_timestamp_seconds,
404        joined_community_fund_timestamp_seconds: wire.joined_community_fund_timestamp_seconds,
405        eight_year_gang_bonus_base_e8s: wire.eight_year_gang_bonus_base_e8s,
406        known_neuron_data: wire.known_neuron_data.map(|known| NnsKnownNeuronData {
407            name: known.name,
408            description: known.description,
409            links: known.links.unwrap_or_default(),
410        }),
411        recent_ballots: wire
412            .recent_ballots
413            .into_iter()
414            .map(|ballot| NnsNeuronBallotRow {
415                proposal_id: ballot.proposal_id.map(|proposal| proposal.id),
416                vote: ballot.vote,
417                vote_text: NnsNeuronVote::from_code(ballot.vote),
418            })
419            .collect(),
420    })
421}
422
423#[cfg(test)]
424mod tests {
425    use super::{NnsNeuronError, map_neuron_info_error};
426
427    #[test]
428    fn neuron_info_maps_only_the_native_not_found_error() {
429        let not_found = map_neuron_info_error(
430            NnsNeuronError::GovernanceResponse {
431                error_type: 4,
432                message: "wording is not part of the contract".to_string(),
433            },
434            42,
435        );
436        assert!(matches!(
437            not_found,
438            NnsNeuronError::NeuronNotFound { neuron_id: 42 }
439        ));
440
441        let unrelated = map_neuron_info_error(
442            NnsNeuronError::GovernanceResponse {
443                error_type: 12,
444                message: "not found text must not override the code".to_string(),
445            },
446            42,
447        );
448        assert!(matches!(
449            unrelated,
450            NnsNeuronError::GovernanceResponse { error_type: 12, .. }
451        ));
452    }
453}