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