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