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#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct NnsNodeProviderCacheRequest {
33 pub icp_root: PathBuf,
34 pub network: String,
35}
36
37#[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#[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#[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#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct CachedNnsNodeProviderReport {
76 pub path: PathBuf,
77 pub report: NnsNodeProviderListReport,
78}
79
80#[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#[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#[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#[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#[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
659trait 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
678struct 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 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 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}