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