Skip to main content

canic_host/nns_node_provider/
mod.rs

1use crate::{
2    cache_file::{
3        CacheFileError, RefreshLockRequest, acquire_refresh_lock, create_directory,
4        write_text_atomically, write_text_output,
5    },
6    subnet_catalog::format_utc_timestamp_secs,
7    table::{ColumnAlign, render_table},
8};
9use canic_ic_registry::{
10    DEFAULT_MAINNET_ENDPOINT, MainnetNodeProviderList, MainnetRegistryFetchRequest,
11    RegistryFetchError, fetch_mainnet_node_provider_list,
12};
13use canic_subnet_catalog::{MAINNET_NETWORK, canonical_principal_text};
14use serde::{Deserialize, Serialize};
15use std::{
16    fs, io,
17    path::{Path, PathBuf},
18};
19use thiserror::Error as ThisError;
20
21pub const DEFAULT_NNS_SOURCE_ENDPOINT: &str = DEFAULT_MAINNET_ENDPOINT;
22pub const DEFAULT_NODE_PROVIDER_REFRESH_LOCK_STALE_SECONDS: u64 = 30 * 60;
23pub const NNS_NODE_PROVIDER_LIST_REPORT_SCHEMA_VERSION: u32 = 1;
24pub const NNS_NODE_PROVIDER_INFO_REPORT_SCHEMA_VERSION: u32 = 1;
25pub const NNS_NODE_PROVIDER_REFRESH_REPORT_SCHEMA_VERSION: u32 = 1;
26const COMPACT_PRINCIPAL_CHARS: usize = 5;
27
28///
29/// NnsNodeProviderCacheRequest
30///
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct NnsNodeProviderCacheRequest {
33    pub icp_root: PathBuf,
34    pub network: String,
35}
36
37///
38/// NnsNodeProviderListRequest
39///
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct NnsNodeProviderListRequest {
42    pub cache: NnsNodeProviderCacheRequest,
43    pub source_endpoint: String,
44    pub now_unix_secs: u64,
45}
46
47///
48/// NnsNodeProviderInfoRequest
49///
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct NnsNodeProviderInfoRequest {
52    pub cache: NnsNodeProviderCacheRequest,
53    pub source_endpoint: String,
54    pub input: String,
55    pub now_unix_secs: u64,
56}
57
58///
59/// NnsNodeProviderRefreshRequest
60///
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct NnsNodeProviderRefreshRequest {
63    pub cache: NnsNodeProviderCacheRequest,
64    pub source_endpoint: String,
65    pub now_unix_secs: u64,
66    pub lock_stale_after_seconds: u64,
67    pub dry_run: bool,
68    pub output_path: Option<PathBuf>,
69}
70
71///
72/// CachedNnsNodeProviderReport
73///
74#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct CachedNnsNodeProviderReport {
76    pub path: PathBuf,
77    pub report: NnsNodeProviderListReport,
78}
79
80///
81/// NnsNodeProviderListReport
82///
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84pub struct NnsNodeProviderListReport {
85    pub schema_version: u32,
86    pub network: String,
87    pub governance_canister_id: String,
88    pub registry_canister_id: String,
89    pub registry_version: u64,
90    pub fetched_at: String,
91    pub source_endpoint: String,
92    pub fetched_by: String,
93    pub node_provider_count: usize,
94    pub node_providers: Vec<NnsNodeProviderRow>,
95}
96
97///
98/// NnsNodeProviderRow
99///
100#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
101pub struct NnsNodeProviderRow {
102    pub node_provider_principal: String,
103    pub name: Option<String>,
104    pub node_count: Option<u32>,
105    pub reward_account_hex: Option<String>,
106}
107
108///
109/// NnsNodeProviderInfoReport
110///
111#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
112pub struct NnsNodeProviderInfoReport {
113    pub schema_version: u32,
114    pub input: String,
115    pub resolved_from: String,
116    pub network: String,
117    pub governance_canister_id: String,
118    pub registry_canister_id: String,
119    pub registry_version: u64,
120    pub fetched_at: String,
121    pub source_endpoint: String,
122    pub fetched_by: String,
123    pub node_provider_principal: String,
124    pub name: Option<String>,
125    pub node_count: Option<u32>,
126    pub reward_account_hex: Option<String>,
127}
128
129///
130/// NnsNodeProviderRefreshReport
131///
132#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
133pub struct NnsNodeProviderRefreshReport {
134    pub schema_version: u32,
135    pub network: String,
136    pub cache_path: String,
137    pub refresh_lock_path: String,
138    pub output_path: Option<String>,
139    pub governance_canister_id: String,
140    pub registry_canister_id: String,
141    pub registry_version: u64,
142    pub fetched_at: String,
143    pub source_endpoint: String,
144    pub fetched_by: String,
145    pub dry_run: bool,
146    pub wrote_cache: bool,
147    pub replaced_existing_cache: bool,
148    pub node_provider_count: usize,
149}
150
151///
152/// NnsNodeProviderHostError
153///
154#[derive(Debug, ThisError)]
155pub enum NnsNodeProviderHostError {
156    #[error(
157        "`canic nns node-provider` supports only the mainnet `ic` network in 0.60\n\nThe NNS node-provider list is queried from the public Internet Computer mainnet governance canister.\nLocal replica NNS governance discovery is not implemented yet.\n\nTry:\n  canic --network ic nns node-provider list"
158    )]
159    UnsupportedNetwork { network: String },
160
161    #[error("node-provider cache is missing at {}", path.display())]
162    MissingCache { path: PathBuf },
163
164    #[error("failed to read node-provider cache at {}: {source}", path.display())]
165    ReadCache { path: PathBuf, source: io::Error },
166
167    #[error("failed to parse node-provider cache at {}: {source}", path.display())]
168    ParseCache {
169        path: PathBuf,
170        source: serde_json::Error,
171    },
172
173    #[error("failed to serialize node-provider cache JSON for {}: {source}", path.display())]
174    SerializeCache {
175        path: PathBuf,
176        source: serde_json::Error,
177    },
178
179    #[error("unsupported node-provider cache schema version {version}; expected {expected}")]
180    UnsupportedCacheSchemaVersion { version: u32, expected: u32 },
181
182    #[error(
183        "cached node-provider network mismatch: path is for {requested}, report is for {actual}"
184    )]
185    NetworkMismatch { requested: String, actual: String },
186
187    #[error("node-provider refresh is already in progress; lock exists at {} since unix_ms={started_at_unix_ms}", path.display())]
188    RefreshAlreadyInProgress {
189        path: PathBuf,
190        started_at_unix_ms: u64,
191    },
192
193    #[error("failed to create node-provider cache directory at {}: {source}", path.display())]
194    CreateCacheDirectory { path: PathBuf, source: io::Error },
195
196    #[error("failed to create node-provider refresh lock at {}: {source}", path.display())]
197    CreateRefreshLock { path: PathBuf, source: io::Error },
198
199    #[error("failed to read node-provider refresh lock at {}: {source}", path.display())]
200    ReadRefreshLock { path: PathBuf, source: io::Error },
201
202    #[error("failed to parse node-provider refresh lock at {}: {source}", path.display())]
203    ParseRefreshLock {
204        path: PathBuf,
205        source: serde_json::Error,
206    },
207
208    #[error("failed to write node-provider refresh lock at {}: {source}", path.display())]
209    WriteRefreshLock { path: PathBuf, source: io::Error },
210
211    #[error("failed to remove node-provider refresh lock at {}: {source}", path.display())]
212    RemoveRefreshLock { path: PathBuf, source: io::Error },
213
214    #[error("live NNS node-provider refresh failed: {0}")]
215    NnsQuery(#[from] RegistryFetchError),
216
217    #[error("failed to write node-provider cache temp file at {}: {source}", path.display())]
218    WriteCacheTemp { path: PathBuf, source: io::Error },
219
220    #[error("failed to sync node-provider cache temp file at {}: {source}", path.display())]
221    SyncCacheTemp { path: PathBuf, source: io::Error },
222
223    #[error("failed to replace node-provider cache at {} from {}: {source}", cache_path.display(), temp_path.display())]
224    ReplaceCache {
225        temp_path: PathBuf,
226        cache_path: PathBuf,
227        source: io::Error,
228    },
229
230    #[error("failed to sync node-provider cache directory at {}: {source}", path.display())]
231    SyncCacheDirectory { path: PathBuf, source: io::Error },
232
233    #[error("failed to write refreshed node-provider output at {}: {source}", path.display())]
234    WriteRefreshOutput { path: PathBuf, source: io::Error },
235
236    #[error("failed to sync refreshed node-provider output at {}: {source}", path.display())]
237    SyncRefreshOutput { path: PathBuf, source: io::Error },
238
239    #[error("node provider {input:?} did not match the mainnet NNS node-provider list")]
240    NodeProviderNotFound { input: String },
241
242    #[error("node-provider prefix {prefix:?} is ambiguous; matches: {matches:?}")]
243    AmbiguousNodeProviderPrefix {
244        prefix: String,
245        matches: Vec<String>,
246    },
247}
248
249#[must_use]
250pub fn nns_node_provider_cache_path(icp_root: &Path, network: &str) -> PathBuf {
251    icp_root
252        .join(".canic")
253        .join("node-provider")
254        .join(network)
255        .join("providers.json")
256}
257
258#[must_use]
259pub fn nns_node_provider_refresh_lock_path(icp_root: &Path, network: &str) -> PathBuf {
260    icp_root
261        .join(".canic")
262        .join("node-provider")
263        .join(network)
264        .join("refresh.lock")
265}
266
267pub fn load_cached_nns_node_provider_report(
268    request: &NnsNodeProviderCacheRequest,
269) -> Result<CachedNnsNodeProviderReport, NnsNodeProviderHostError> {
270    enforce_mainnet_network(&request.network)?;
271    let path = nns_node_provider_cache_path(&request.icp_root, &request.network);
272    if !path.is_file() {
273        return Err(NnsNodeProviderHostError::MissingCache { path });
274    }
275    let data = fs::read_to_string(&path).map_err(|source| NnsNodeProviderHostError::ReadCache {
276        path: path.clone(),
277        source,
278    })?;
279    let report = serde_json::from_str::<NnsNodeProviderListReport>(&data).map_err(|source| {
280        NnsNodeProviderHostError::ParseCache {
281            path: path.clone(),
282            source,
283        }
284    })?;
285    if report.schema_version != NNS_NODE_PROVIDER_LIST_REPORT_SCHEMA_VERSION {
286        return Err(NnsNodeProviderHostError::UnsupportedCacheSchemaVersion {
287            version: report.schema_version,
288            expected: NNS_NODE_PROVIDER_LIST_REPORT_SCHEMA_VERSION,
289        });
290    }
291    if report.network != request.network {
292        return Err(NnsNodeProviderHostError::NetworkMismatch {
293            requested: request.network.clone(),
294            actual: report.network,
295        });
296    }
297    Ok(CachedNnsNodeProviderReport { path, report })
298}
299
300pub fn build_nns_node_provider_list_report(
301    request: &NnsNodeProviderListRequest,
302) -> Result<NnsNodeProviderListReport, NnsNodeProviderHostError> {
303    build_nns_node_provider_list_report_with_source(request, &LiveNnsNodeProviderSource)
304}
305
306pub fn build_nns_node_provider_info_report(
307    request: &NnsNodeProviderInfoRequest,
308) -> Result<NnsNodeProviderInfoReport, NnsNodeProviderHostError> {
309    build_nns_node_provider_info_report_with_source(request, &LiveNnsNodeProviderSource)
310}
311
312pub fn refresh_nns_node_provider_report(
313    request: &NnsNodeProviderRefreshRequest,
314) -> Result<NnsNodeProviderRefreshReport, NnsNodeProviderHostError> {
315    refresh_nns_node_provider_report_with_source(request, &LiveNnsNodeProviderSource)
316}
317
318fn build_nns_node_provider_list_report_with_source(
319    request: &NnsNodeProviderListRequest,
320    source: &dyn NnsNodeProviderSource,
321) -> Result<NnsNodeProviderListReport, NnsNodeProviderHostError> {
322    match load_cached_nns_node_provider_report(&request.cache) {
323        Ok(cached) => Ok(cached.report),
324        Err(NnsNodeProviderHostError::MissingCache { .. }) => {
325            let refresh_request = NnsNodeProviderRefreshRequest {
326                cache: request.cache.clone(),
327                source_endpoint: request.source_endpoint.clone(),
328                now_unix_secs: request.now_unix_secs,
329                lock_stale_after_seconds: DEFAULT_NODE_PROVIDER_REFRESH_LOCK_STALE_SECONDS,
330                dry_run: false,
331                output_path: None,
332            };
333            let (report, _) =
334                refresh_nns_node_provider_cache_with_source(&refresh_request, source)?;
335            Ok(report)
336        }
337        Err(err) => Err(err),
338    }
339}
340
341fn build_nns_node_provider_info_report_with_source(
342    request: &NnsNodeProviderInfoRequest,
343    source: &dyn NnsNodeProviderSource,
344) -> Result<NnsNodeProviderInfoReport, NnsNodeProviderHostError> {
345    let list_request = NnsNodeProviderListRequest {
346        cache: request.cache.clone(),
347        source_endpoint: request.source_endpoint.clone(),
348        now_unix_secs: request.now_unix_secs,
349    };
350    let report = build_nns_node_provider_list_report_with_source(&list_request, source)?;
351    let (provider, resolved_from) = resolve_node_provider(&report, &request.input)?;
352    Ok(NnsNodeProviderInfoReport {
353        schema_version: NNS_NODE_PROVIDER_INFO_REPORT_SCHEMA_VERSION,
354        input: request.input.clone(),
355        resolved_from,
356        network: report.network,
357        governance_canister_id: report.governance_canister_id,
358        registry_canister_id: report.registry_canister_id,
359        registry_version: report.registry_version,
360        fetched_at: report.fetched_at,
361        source_endpoint: report.source_endpoint,
362        fetched_by: report.fetched_by,
363        node_provider_principal: provider.node_provider_principal,
364        name: provider.name,
365        node_count: provider.node_count,
366        reward_account_hex: provider.reward_account_hex,
367    })
368}
369
370fn refresh_nns_node_provider_report_with_source(
371    request: &NnsNodeProviderRefreshRequest,
372    source: &dyn NnsNodeProviderSource,
373) -> Result<NnsNodeProviderRefreshReport, NnsNodeProviderHostError> {
374    refresh_nns_node_provider_cache_with_source(request, source).map(|(_, report)| report)
375}
376
377fn refresh_nns_node_provider_cache_with_source(
378    request: &NnsNodeProviderRefreshRequest,
379    source: &dyn NnsNodeProviderSource,
380) -> Result<(NnsNodeProviderListReport, NnsNodeProviderRefreshReport), NnsNodeProviderHostError> {
381    enforce_mainnet_network(&request.cache.network)?;
382    let cache_path = nns_node_provider_cache_path(&request.cache.icp_root, &request.cache.network);
383    let lock_path =
384        nns_node_provider_refresh_lock_path(&request.cache.icp_root, &request.cache.network);
385    let cache_dir = cache_path
386        .parent()
387        .expect("node-provider cache path always has parent")
388        .to_path_buf();
389    create_directory(&cache_dir).map_err(node_provider_cache_error)?;
390    let lock = acquire_refresh_lock(RefreshLockRequest {
391        lock_path: &lock_path,
392        target_path: &cache_path,
393        network: &request.cache.network,
394        now_unix_secs: request.now_unix_secs,
395        lock_stale_after_seconds: request.lock_stale_after_seconds,
396    })
397    .map_err(node_provider_cache_error)?;
398    let replaced_existing_cache = cache_path.is_file();
399    let report = fetch_nns_node_provider_list_report_with_source(
400        &request.cache.network,
401        &request.source_endpoint,
402        request.now_unix_secs,
403        source,
404    )?;
405    let report_json = serde_json::to_string_pretty(&report).map_err(|source| {
406        NnsNodeProviderHostError::SerializeCache {
407            path: cache_path.clone(),
408            source,
409        }
410    })?;
411    if let Some(output_path) = &request.output_path {
412        write_text_output(output_path, &report_json).map_err(node_provider_cache_error)?;
413    }
414    if !request.dry_run {
415        write_text_atomically(&cache_path, &report_json).map_err(node_provider_cache_error)?;
416    }
417    lock.release().map_err(node_provider_cache_error)?;
418    let refresh_report = NnsNodeProviderRefreshReport {
419        schema_version: NNS_NODE_PROVIDER_REFRESH_REPORT_SCHEMA_VERSION,
420        network: report.network.clone(),
421        cache_path: cache_path.display().to_string(),
422        refresh_lock_path: lock_path.display().to_string(),
423        output_path: request
424            .output_path
425            .as_ref()
426            .map(|path| path.display().to_string()),
427        governance_canister_id: report.governance_canister_id.clone(),
428        registry_canister_id: report.registry_canister_id.clone(),
429        registry_version: report.registry_version,
430        fetched_at: report.fetched_at.clone(),
431        source_endpoint: report.source_endpoint.clone(),
432        fetched_by: report.fetched_by.clone(),
433        dry_run: request.dry_run,
434        wrote_cache: !request.dry_run,
435        replaced_existing_cache,
436        node_provider_count: report.node_provider_count,
437    };
438    Ok((report, refresh_report))
439}
440
441fn fetch_nns_node_provider_list_report_with_source(
442    network: &str,
443    source_endpoint: &str,
444    now_unix_secs: u64,
445    source: &dyn NnsNodeProviderSource,
446) -> Result<NnsNodeProviderListReport, NnsNodeProviderHostError> {
447    enforce_mainnet_network(network)?;
448    let fetched_at = format_utc_timestamp_secs(now_unix_secs);
449    let mut fetch_request = MainnetRegistryFetchRequest::new(fetched_at);
450    fetch_request.endpoint = source_endpoint.to_string();
451    let list = source.fetch_node_providers(&fetch_request)?;
452    Ok(node_provider_report_from_list(list))
453}
454
455fn node_provider_cache_error(err: CacheFileError) -> NnsNodeProviderHostError {
456    match err {
457        CacheFileError::CreateDirectory { path, source } => {
458            NnsNodeProviderHostError::CreateCacheDirectory { path, source }
459        }
460        CacheFileError::CreateRefreshLock { path, source } => {
461            NnsNodeProviderHostError::CreateRefreshLock { path, source }
462        }
463        CacheFileError::ReadRefreshLock { path, source } => {
464            NnsNodeProviderHostError::ReadRefreshLock { path, source }
465        }
466        CacheFileError::ParseRefreshLock { path, source } => {
467            NnsNodeProviderHostError::ParseRefreshLock { path, source }
468        }
469        CacheFileError::WriteRefreshLock { path, source } => {
470            NnsNodeProviderHostError::WriteRefreshLock { path, source }
471        }
472        CacheFileError::RemoveRefreshLock { path, source } => {
473            NnsNodeProviderHostError::RemoveRefreshLock { path, source }
474        }
475        CacheFileError::RefreshAlreadyInProgress {
476            path,
477            started_at_unix_ms,
478        } => NnsNodeProviderHostError::RefreshAlreadyInProgress {
479            path,
480            started_at_unix_ms,
481        },
482        CacheFileError::WriteTemp { path, source } => {
483            NnsNodeProviderHostError::WriteCacheTemp { path, source }
484        }
485        CacheFileError::SyncTemp { path, source } => {
486            NnsNodeProviderHostError::SyncCacheTemp { path, source }
487        }
488        CacheFileError::Replace {
489            temp_path,
490            target_path,
491            source,
492        } => NnsNodeProviderHostError::ReplaceCache {
493            temp_path,
494            cache_path: target_path,
495            source,
496        },
497        CacheFileError::SyncDirectory { path, source } => {
498            NnsNodeProviderHostError::SyncCacheDirectory { path, source }
499        }
500        CacheFileError::WriteOutput { path, source } => {
501            NnsNodeProviderHostError::WriteRefreshOutput { path, source }
502        }
503        CacheFileError::SyncOutput { path, source } => {
504            NnsNodeProviderHostError::SyncRefreshOutput { path, source }
505        }
506    }
507}
508
509#[must_use]
510pub fn nns_node_provider_list_report_text(report: &NnsNodeProviderListReport) -> String {
511    let mut lines = Vec::new();
512    lines.push(format!(
513        "node_providers: {} count {} fetched_at {}",
514        report.network, report.node_provider_count, report.fetched_at
515    ));
516    if report.node_providers.is_empty() {
517        lines.push("node providers: none".to_string());
518        return lines.join("\n");
519    }
520
521    let headers = ["NODE_PROVIDER", "NODES"];
522    let rows = report
523        .node_providers
524        .iter()
525        .map(|provider| {
526            [
527                compact_principal(&provider.node_provider_principal),
528                node_count_text(provider.node_count),
529            ]
530        })
531        .collect::<Vec<_>>();
532    let alignments = [ColumnAlign::Left, ColumnAlign::Right];
533    lines.push(render_table(&headers, &rows, &alignments));
534    lines.join("\n")
535}
536
537#[must_use]
538pub fn nns_node_provider_list_report_verbose_text(report: &NnsNodeProviderListReport) -> String {
539    let mut lines = Vec::new();
540    lines.push(format!("source_endpoint: {}", report.source_endpoint));
541    lines.push(format!("fetched_by: {}", report.fetched_by));
542    if report.node_providers.is_empty() {
543        lines.push("node providers: none".to_string());
544        return lines.join("\n");
545    }
546
547    let headers = [
548        "NODE_PROVIDER",
549        "NODES",
550        "REWARD_ACCOUNT",
551        "REGISTRY_VERSION",
552        "FETCHED_AT",
553    ];
554    let rows = report
555        .node_providers
556        .iter()
557        .map(|provider| {
558            [
559                provider.node_provider_principal.clone(),
560                node_count_text(provider.node_count),
561                text_or_dash(provider.reward_account_hex.as_deref()).to_string(),
562                report.registry_version.to_string(),
563                report.fetched_at.clone(),
564            ]
565        })
566        .collect::<Vec<_>>();
567    let alignments = [
568        ColumnAlign::Left,
569        ColumnAlign::Right,
570        ColumnAlign::Left,
571        ColumnAlign::Right,
572        ColumnAlign::Left,
573    ];
574    lines.push(render_table(&headers, &rows, &alignments));
575    lines.join("\n")
576}
577
578#[must_use]
579pub fn nns_node_provider_info_report_text(report: &NnsNodeProviderInfoReport) -> String {
580    let mut lines = Vec::new();
581    lines.push(format!("input: {}", report.input));
582    lines.push(format!("resolved_from: {}", report.resolved_from));
583    lines.push(format!(
584        "node_provider_principal: {}",
585        report.node_provider_principal
586    ));
587    lines.push(format!(
588        "node_count: {}",
589        node_count_text(report.node_count)
590    ));
591    lines.push(format!(
592        "reward_account_hex: {}",
593        text_or_dash(report.reward_account_hex.as_deref())
594    ));
595    lines.push(format!(
596        "governance_canister_id: {}",
597        report.governance_canister_id
598    ));
599    lines.push(format!(
600        "registry_canister_id: {}",
601        report.registry_canister_id
602    ));
603    lines.push(format!("registry_version: {}", report.registry_version));
604    lines.push(format!("network: {}", report.network));
605    lines.push(format!("fetched_at: {}", report.fetched_at));
606    lines.push(format!("source_endpoint: {}", report.source_endpoint));
607    lines.push(format!("fetched_by: {}", report.fetched_by));
608    lines.join("\n")
609}
610
611#[must_use]
612pub fn nns_node_provider_refresh_report_text(report: &NnsNodeProviderRefreshReport) -> String {
613    [
614        format!("network: {}", report.network),
615        format!("cache_path: {}", report.cache_path),
616        format!("refresh_lock_path: {}", report.refresh_lock_path),
617        format!("governance_canister_id: {}", report.governance_canister_id),
618        format!("registry_canister_id: {}", report.registry_canister_id),
619        format!("registry_version: {}", report.registry_version),
620        format!("fetched_at: {}", report.fetched_at),
621        format!("source_endpoint: {}", report.source_endpoint),
622        format!("fetched_by: {}", report.fetched_by),
623        format!("dry_run: {}", yes_no(report.dry_run)),
624        format!("wrote_cache: {}", yes_no(report.wrote_cache)),
625        format!(
626            "replaced_existing_cache: {}",
627            yes_no(report.replaced_existing_cache)
628        ),
629        format!("node_provider_count: {}", report.node_provider_count),
630    ]
631    .join("\n")
632}
633
634fn node_provider_report_from_list(list: MainnetNodeProviderList) -> NnsNodeProviderListReport {
635    let node_providers = list
636        .node_providers
637        .into_iter()
638        .map(|provider| NnsNodeProviderRow {
639            node_provider_principal: provider.principal,
640            name: None,
641            node_count: provider.node_count,
642            reward_account_hex: provider.reward_account_hex,
643        })
644        .collect::<Vec<_>>();
645    NnsNodeProviderListReport {
646        schema_version: NNS_NODE_PROVIDER_LIST_REPORT_SCHEMA_VERSION,
647        network: list.network,
648        governance_canister_id: list.governance_canister_id,
649        registry_canister_id: list.registry_canister_id,
650        registry_version: list.registry_version,
651        fetched_at: list.fetched_at,
652        source_endpoint: list.source_endpoint,
653        fetched_by: list.fetched_by,
654        node_provider_count: node_providers.len(),
655        node_providers,
656    }
657}
658
659///
660/// NnsNodeProviderSource
661///
662trait NnsNodeProviderSource {
663    fn fetch_node_providers(
664        &self,
665        request: &MainnetRegistryFetchRequest,
666    ) -> Result<MainnetNodeProviderList, NnsNodeProviderHostError>;
667}
668
669fn enforce_mainnet_network(network: &str) -> Result<(), NnsNodeProviderHostError> {
670    if network == MAINNET_NETWORK {
671        return Ok(());
672    }
673    Err(NnsNodeProviderHostError::UnsupportedNetwork {
674        network: network.to_string(),
675    })
676}
677
678///
679/// LiveNnsNodeProviderSource
680///
681struct LiveNnsNodeProviderSource;
682
683impl NnsNodeProviderSource for LiveNnsNodeProviderSource {
684    fn fetch_node_providers(
685        &self,
686        request: &MainnetRegistryFetchRequest,
687    ) -> Result<MainnetNodeProviderList, NnsNodeProviderHostError> {
688        Ok(fetch_mainnet_node_provider_list(request)?)
689    }
690}
691
692fn resolve_node_provider(
693    report: &NnsNodeProviderListReport,
694    input: &str,
695) -> Result<(NnsNodeProviderRow, String), NnsNodeProviderHostError> {
696    if let Ok(principal) = canonical_principal_text(input)
697        && let Some(provider) = report
698            .node_providers
699            .iter()
700            .find(|provider| provider.node_provider_principal == principal)
701    {
702        return Ok((provider.clone(), "node_provider_principal".to_string()));
703    }
704
705    let prefix = input.trim().to_ascii_lowercase();
706    if prefix.is_empty() {
707        return Err(NnsNodeProviderHostError::NodeProviderNotFound {
708            input: input.to_string(),
709        });
710    }
711    let matches = report
712        .node_providers
713        .iter()
714        .filter(|provider| provider.node_provider_principal.starts_with(&prefix))
715        .cloned()
716        .collect::<Vec<_>>();
717    match matches.as_slice() {
718        [provider] => Ok((
719            provider.clone(),
720            "node_provider_principal_prefix".to_string(),
721        )),
722        [] => Err(NnsNodeProviderHostError::NodeProviderNotFound {
723            input: input.to_string(),
724        }),
725        _ => Err(NnsNodeProviderHostError::AmbiguousNodeProviderPrefix {
726            prefix,
727            matches: matches
728                .into_iter()
729                .map(|provider| provider.node_provider_principal)
730                .collect(),
731        }),
732    }
733}
734
735fn compact_principal(value: &str) -> String {
736    value.chars().take(COMPACT_PRINCIPAL_CHARS).collect()
737}
738
739fn node_count_text(value: Option<u32>) -> String {
740    value.map_or_else(|| "unknown".to_string(), |count| count.to_string())
741}
742
743fn text_or_dash(value: Option<&str>) -> &str {
744    value.filter(|text| !text.is_empty()).unwrap_or("-")
745}
746
747const fn yes_no(value: bool) -> &'static str {
748    if value { "yes" } else { "no" }
749}
750
751#[cfg(test)]
752mod tests {
753    use super::*;
754    use canic_ic_registry::{MAINNET_GOVERNANCE_CANISTER_ID, MainnetNodeProvider};
755    use canic_subnet_catalog::MAINNET_REGISTRY_CANISTER_ID;
756    use std::{
757        fs,
758        sync::atomic::{AtomicU64, Ordering},
759    };
760
761    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
762
763    #[test]
764    fn node_provider_report_uses_live_governance_source() {
765        let request = NnsNodeProviderListRequest {
766            cache: test_cache_request(MAINNET_NETWORK, "uses-live-source"),
767            source_endpoint: "https://icp-api.io".to_string(),
768            now_unix_secs: 1_780_531_200,
769        };
770        let report = build_nns_node_provider_list_report_with_source(
771            &request,
772            &FixtureNodeProviderSource {
773                node_providers: vec![
774                    MainnetNodeProvider {
775                        principal: "aaaaa-aa".to_string(),
776                        node_count: Some(3),
777                        reward_account_hex: Some("abcd".to_string()),
778                    },
779                    MainnetNodeProvider {
780                        principal: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
781                        node_count: None,
782                        reward_account_hex: None,
783                    },
784                ],
785            },
786        )
787        .expect("node provider report");
788
789        assert_eq!(report.schema_version, 1);
790        assert_eq!(report.network, MAINNET_NETWORK);
791        assert_eq!(
792            report.governance_canister_id,
793            MAINNET_GOVERNANCE_CANISTER_ID
794        );
795        assert_eq!(report.registry_canister_id, MAINNET_REGISTRY_CANISTER_ID);
796        assert_eq!(report.registry_version, 42);
797        assert_eq!(report.fetched_at, "2026-06-04T00:00:00Z");
798        assert_eq!(report.node_provider_count, 2);
799        assert_eq!(report.node_providers[0].node_provider_principal, "aaaaa-aa");
800        assert_eq!(report.node_providers[0].name, None);
801        assert_eq!(report.node_providers[0].node_count, Some(3));
802        assert_eq!(
803            report.node_providers[0].reward_account_hex.as_deref(),
804            Some("abcd")
805        );
806    }
807
808    #[test]
809    fn node_provider_text_keeps_table_narrow() {
810        let report = NnsNodeProviderListReport {
811            schema_version: 1,
812            network: MAINNET_NETWORK.to_string(),
813            governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
814            registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
815            registry_version: 42,
816            fetched_at: "2026-06-04T00:00:00Z".to_string(),
817            source_endpoint: "https://icp-api.io".to_string(),
818            fetched_by: "test".to_string(),
819            node_provider_count: 1,
820            node_providers: vec![NnsNodeProviderRow {
821                node_provider_principal: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
822                name: Some("DFINITY".to_string()),
823                node_count: Some(13),
824                reward_account_hex: Some("abcd".to_string()),
825            }],
826        };
827
828        let text = nns_node_provider_list_report_text(&report);
829
830        assert!(text.contains("node_providers: ic count 1"));
831        assert!(text.contains("NODE_PROVIDER"));
832        assert!(text.contains("ryjl3"));
833        assert!(text.contains("13"));
834        assert!(!text.contains("NAME"));
835        assert!(!text.contains("DFINITY"));
836        assert!(!text.contains("ryjl3-tyaaa-aaaaa-aaaba-cai"));
837        assert!(!text.contains("abcd"));
838    }
839
840    #[test]
841    fn node_provider_verbose_text_keeps_full_metadata() {
842        let report = node_provider_report_fixture();
843
844        let text = nns_node_provider_list_report_verbose_text(&report);
845
846        assert!(text.contains("source_endpoint: https://icp-api.io"));
847        assert!(text.contains("ryjl3-tyaaa-aaaaa-aaaba-cai"));
848        assert!(text.contains("abcd"));
849        assert!(text.contains("42"));
850        assert!(text.contains("FETCHED_AT"));
851        assert!(!text.contains("NAME"));
852        assert!(!text.contains("DFINITY"));
853    }
854
855    #[test]
856    fn node_provider_info_resolves_exact_principal() {
857        let request = NnsNodeProviderInfoRequest {
858            cache: test_cache_request(MAINNET_NETWORK, "info-exact"),
859            source_endpoint: "https://icp-api.io".to_string(),
860            input: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
861            now_unix_secs: 1_780_531_200,
862        };
863        let report = build_nns_node_provider_info_report_with_source(
864            &request,
865            &FixtureNodeProviderSource {
866                node_providers: vec![MainnetNodeProvider {
867                    principal: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
868                    node_count: Some(13),
869                    reward_account_hex: Some("abcd".to_string()),
870                }],
871            },
872        )
873        .expect("node provider info");
874
875        assert_eq!(report.input, "ryjl3-tyaaa-aaaaa-aaaba-cai");
876        assert_eq!(report.resolved_from, "node_provider_principal");
877        assert_eq!(
878            report.node_provider_principal,
879            "ryjl3-tyaaa-aaaaa-aaaba-cai"
880        );
881        assert_eq!(report.node_count, Some(13));
882        assert_eq!(report.reward_account_hex.as_deref(), Some("abcd"));
883    }
884
885    #[test]
886    fn node_provider_info_resolves_unique_prefix() {
887        let report = node_provider_report_fixture();
888
889        let (provider, resolved_from) =
890            resolve_node_provider(&report, "ryjl").expect("prefix resolves");
891
892        assert_eq!(resolved_from, "node_provider_principal_prefix");
893        assert_eq!(
894            provider.node_provider_principal,
895            "ryjl3-tyaaa-aaaaa-aaaba-cai"
896        );
897    }
898
899    #[test]
900    fn node_provider_info_rejects_ambiguous_prefix() {
901        let report = NnsNodeProviderListReport {
902            schema_version: 1,
903            network: MAINNET_NETWORK.to_string(),
904            governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
905            registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
906            registry_version: 42,
907            fetched_at: "2026-06-04T00:00:00Z".to_string(),
908            source_endpoint: "https://icp-api.io".to_string(),
909            fetched_by: "test".to_string(),
910            node_provider_count: 2,
911            node_providers: vec![
912                NnsNodeProviderRow {
913                    node_provider_principal: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
914                    name: None,
915                    node_count: None,
916                    reward_account_hex: None,
917                },
918                NnsNodeProviderRow {
919                    node_provider_principal: "rwlgt-iiaaa-aaaaa-aaaaa-cai".to_string(),
920                    name: None,
921                    node_count: None,
922                    reward_account_hex: None,
923                },
924            ],
925        };
926
927        let err = resolve_node_provider(&report, "r").expect_err("ambiguous");
928
929        assert!(matches!(
930            err,
931            NnsNodeProviderHostError::AmbiguousNodeProviderPrefix { prefix, matches }
932                if prefix == "r" && matches.len() == 2
933        ));
934    }
935
936    #[test]
937    fn node_provider_info_text_renders_detail_lines() {
938        let report = NnsNodeProviderInfoReport {
939            schema_version: 1,
940            input: "ryjl".to_string(),
941            resolved_from: "node_provider_principal_prefix".to_string(),
942            network: MAINNET_NETWORK.to_string(),
943            governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
944            registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
945            registry_version: 42,
946            fetched_at: "2026-06-04T00:00:00Z".to_string(),
947            source_endpoint: "https://icp-api.io".to_string(),
948            fetched_by: "test".to_string(),
949            node_provider_principal: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
950            name: None,
951            node_count: None,
952            reward_account_hex: Some("abcd".to_string()),
953        };
954
955        let text = nns_node_provider_info_report_text(&report);
956
957        assert!(text.contains("resolved_from: node_provider_principal_prefix"));
958        assert!(text.contains("node_provider_principal: ryjl3-tyaaa-aaaaa-aaaba-cai"));
959        assert!(!text.contains("name:"));
960        assert!(text.contains("node_count: unknown"));
961        assert!(text.contains("reward_account_hex: abcd"));
962        assert!(text.contains("registry_version: 42"));
963    }
964
965    #[test]
966    fn node_provider_list_rejects_local_network() {
967        let request = NnsNodeProviderListRequest {
968            cache: test_cache_request("local", "local-rejected"),
969            source_endpoint: "https://icp-api.io".to_string(),
970            now_unix_secs: 1,
971        };
972
973        let err = build_nns_node_provider_list_report_with_source(
974            &request,
975            &FixtureNodeProviderSource {
976                node_providers: Vec::new(),
977            },
978        )
979        .expect_err("local rejected");
980
981        assert!(err.to_string().contains("supports only the mainnet `ic`"));
982    }
983
984    #[test]
985    fn node_provider_refresh_writes_cache_and_list_reads_it() {
986        let cache = test_cache_request(MAINNET_NETWORK, "refresh-cache");
987        let refresh_request = NnsNodeProviderRefreshRequest {
988            cache: cache.clone(),
989            source_endpoint: "https://icp-api.io".to_string(),
990            now_unix_secs: 1_780_531_200,
991            lock_stale_after_seconds: DEFAULT_NODE_PROVIDER_REFRESH_LOCK_STALE_SECONDS,
992            dry_run: false,
993            output_path: None,
994        };
995        let refresh_report = refresh_nns_node_provider_report_with_source(
996            &refresh_request,
997            &FixtureNodeProviderSource {
998                node_providers: vec![MainnetNodeProvider {
999                    principal: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
1000                    node_count: Some(13),
1001                    reward_account_hex: Some("abcd".to_string()),
1002                }],
1003            },
1004        )
1005        .expect("refresh report");
1006
1007        assert!(nns_node_provider_cache_path(&cache.icp_root, &cache.network).is_file());
1008        assert!(refresh_report.wrote_cache);
1009        assert_eq!(refresh_report.node_provider_count, 1);
1010
1011        let list_request = NnsNodeProviderListRequest {
1012            cache,
1013            source_endpoint: "https://unused.example".to_string(),
1014            now_unix_secs: 1_780_531_300,
1015        };
1016        let report = build_nns_node_provider_list_report_with_source(
1017            &list_request,
1018            &FailingNodeProviderSource,
1019        )
1020        .expect("cached report");
1021
1022        assert_eq!(report.source_endpoint, "https://icp-api.io");
1023        assert_eq!(report.node_providers.len(), 1);
1024        assert_eq!(report.node_providers[0].node_count, Some(13));
1025    }
1026
1027    fn node_provider_report_fixture() -> NnsNodeProviderListReport {
1028        NnsNodeProviderListReport {
1029            schema_version: 1,
1030            network: MAINNET_NETWORK.to_string(),
1031            governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
1032            registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
1033            registry_version: 42,
1034            fetched_at: "2026-06-04T00:00:00Z".to_string(),
1035            source_endpoint: "https://icp-api.io".to_string(),
1036            fetched_by: "test".to_string(),
1037            node_provider_count: 2,
1038            node_providers: vec![
1039                NnsNodeProviderRow {
1040                    node_provider_principal: "aaaaa-aa".to_string(),
1041                    name: None,
1042                    node_count: Some(3),
1043                    reward_account_hex: None,
1044                },
1045                NnsNodeProviderRow {
1046                    node_provider_principal: "ryjl3-tyaaa-aaaaa-aaaba-cai".to_string(),
1047                    name: Some("DFINITY".to_string()),
1048                    node_count: Some(13),
1049                    reward_account_hex: Some("abcd".to_string()),
1050                },
1051            ],
1052        }
1053    }
1054
1055    ///
1056    /// FixtureNodeProviderSource
1057    ///
1058    struct FixtureNodeProviderSource {
1059        node_providers: Vec<MainnetNodeProvider>,
1060    }
1061
1062    impl NnsNodeProviderSource for FixtureNodeProviderSource {
1063        fn fetch_node_providers(
1064            &self,
1065            request: &MainnetRegistryFetchRequest,
1066        ) -> Result<MainnetNodeProviderList, NnsNodeProviderHostError> {
1067            Ok(MainnetNodeProviderList {
1068                network: MAINNET_NETWORK.to_string(),
1069                governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
1070                registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
1071                registry_version: 42,
1072                fetched_at: request.fetched_at.clone(),
1073                fetched_by: "test".to_string(),
1074                source_endpoint: request.endpoint.clone(),
1075                node_providers: self.node_providers.clone(),
1076            })
1077        }
1078    }
1079
1080    ///
1081    /// FailingNodeProviderSource
1082    ///
1083    struct FailingNodeProviderSource;
1084
1085    impl NnsNodeProviderSource for FailingNodeProviderSource {
1086        fn fetch_node_providers(
1087            &self,
1088            _request: &MainnetRegistryFetchRequest,
1089        ) -> Result<MainnetNodeProviderList, NnsNodeProviderHostError> {
1090            Err(NnsNodeProviderHostError::NodeProviderNotFound {
1091                input: "unexpected-live-fetch".to_string(),
1092            })
1093        }
1094    }
1095
1096    fn test_cache_request(network: &str, name: &str) -> NnsNodeProviderCacheRequest {
1097        let count = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
1098        let icp_root = std::env::temp_dir().join(format!(
1099            "canic-node-provider-{name}-{}-{count}",
1100            std::process::id()
1101        ));
1102        let _ = fs::remove_dir_all(&icp_root);
1103        NnsNodeProviderCacheRequest {
1104            icp_root,
1105            network: network.to_string(),
1106        }
1107    }
1108}