Skip to main content

canic_ic_registry/
lib.rs

1//! Live mainnet IC NNS registry adapter for Canic host tools.
2
3pub(crate) mod proto;
4
5use candid::{CandidType, Decode, Deserialize, Encode, Principal};
6use canic_subnet_catalog::{
7    CATALOG_SCHEMA_VERSION, CatalogError, ClassificationSource, GeographicScope, MAINNET_NETWORK,
8    MAINNET_REGISTRY_CANISTER_ID, RoutingRange, SubnetCatalog, SubnetInfo, SubnetKind,
9    SubnetSpecialization,
10};
11use futures::{StreamExt, TryStreamExt, stream};
12use ic_agent::Agent;
13use prost::Message;
14use proto::{
15    CanisterId, DataCenterRecord, LargeValueChunkKeys, NodeOperatorRecord, NodeRecord,
16    RegistryErrorCode, RegistryGetLatestVersionResponse, RegistryGetValueRequest,
17    RegistryGetValueResponse, RoutingTable, SubnetId, SubnetListRecord, SubnetRecord, SubnetType,
18    UInt64Value, registry_get_value_response,
19};
20use serde::Serialize;
21use sha2::{Digest, Sha256};
22use std::collections::{BTreeMap, BTreeSet};
23use thiserror::Error as ThisError;
24
25pub const DEFAULT_MAINNET_ENDPOINT: &str = "https://icp-api.io";
26pub const MAINNET_GOVERNANCE_CANISTER_ID: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai";
27
28const SUBNET_LIST_KEY: &str = "subnet_list";
29const ROUTING_TABLE_KEY: &str = "routing_table";
30const SUBNET_RECORD_KEY_PREFIX: &str = "subnet_record_";
31const NODE_RECORD_KEY_PREFIX: &str = "node_record_";
32const NODE_OPERATOR_RECORD_KEY_PREFIX: &str = "node_operator_record_";
33const DATA_CENTER_RECORD_KEY_PREFIX: &str = "data_center_record_";
34const NODE_PROVIDER_ENRICHMENT_CONCURRENCY: usize = 32;
35const FIDUCIARY_SUBNET: &str = "pzp6e-ekpqk-3c5x7-2h6so-njoeq-mt45d-h3h6c-q3mxf-vpeq5-fk5o7-yae";
36const EUROPEAN_SUBNET: &str = "bkfrj-6k62g-dycql-7h53p-atvkj-zg4to-gaogh-netha-ptybj-ntsgw-rqe";
37
38///
39/// MainnetRegistryFetchRequest
40///
41#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct MainnetRegistryFetchRequest {
43    pub endpoint: String,
44    pub fetched_at: String,
45    pub fetched_by: String,
46}
47
48impl MainnetRegistryFetchRequest {
49    #[must_use]
50    pub fn new(fetched_at: String) -> Self {
51        Self {
52            endpoint: DEFAULT_MAINNET_ENDPOINT.to_string(),
53            fetched_at,
54            fetched_by: "canic-ic-registry".to_string(),
55        }
56    }
57}
58
59///
60/// MainnetRegistryVersion
61///
62#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
63pub struct MainnetRegistryVersion {
64    pub network: String,
65    pub registry_canister_id: String,
66    pub registry_version: u64,
67    pub fetched_at: String,
68    pub fetched_by: String,
69    pub source_endpoint: String,
70}
71
72///
73/// MainnetNodeProviderList
74///
75#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
76pub struct MainnetNodeProviderList {
77    pub network: String,
78    pub governance_canister_id: String,
79    pub registry_canister_id: String,
80    pub registry_version: u64,
81    pub fetched_at: String,
82    pub fetched_by: String,
83    pub source_endpoint: String,
84    pub node_providers: Vec<MainnetNodeProvider>,
85}
86
87///
88/// MainnetNodeProvider
89///
90#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
91pub struct MainnetNodeProvider {
92    pub principal: String,
93    pub node_count: Option<u32>,
94    pub reward_account_hex: Option<String>,
95}
96
97///
98/// MainnetNodeOperatorList
99///
100#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
101pub struct MainnetNodeOperatorList {
102    pub network: String,
103    pub registry_canister_id: String,
104    pub registry_version: u64,
105    pub fetched_at: String,
106    pub fetched_by: String,
107    pub source_endpoint: String,
108    pub node_operators: Vec<MainnetNodeOperator>,
109}
110
111///
112/// MainnetNodeOperator
113///
114#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
115pub struct MainnetNodeOperator {
116    pub principal: String,
117    pub node_provider_principal: String,
118    pub node_allowance: u64,
119    pub data_center_id: String,
120    pub node_count: Option<u32>,
121}
122
123///
124/// MainnetNodeList
125///
126#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
127pub struct MainnetNodeList {
128    pub network: String,
129    pub registry_canister_id: String,
130    pub registry_version: u64,
131    pub fetched_at: String,
132    pub fetched_by: String,
133    pub source_endpoint: String,
134    pub nodes: Vec<MainnetNode>,
135}
136
137///
138/// MainnetNode
139///
140#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
141pub struct MainnetNode {
142    pub principal: String,
143    pub node_operator_principal: String,
144    pub node_provider_principal: String,
145    pub subnet_principal: String,
146    pub subnet_kind: String,
147    pub data_center_id: String,
148}
149
150///
151/// MainnetDataCenterList
152///
153#[derive(Clone, Debug, PartialEq, Serialize)]
154pub struct MainnetDataCenterList {
155    pub network: String,
156    pub registry_canister_id: String,
157    pub registry_version: u64,
158    pub fetched_at: String,
159    pub fetched_by: String,
160    pub source_endpoint: String,
161    pub data_centers: Vec<MainnetDataCenter>,
162}
163
164///
165/// MainnetDataCenter
166///
167#[derive(Clone, Debug, PartialEq, Serialize)]
168pub struct MainnetDataCenter {
169    pub id: String,
170    pub region: String,
171    pub owner: String,
172    pub latitude: Option<f32>,
173    pub longitude: Option<f32>,
174    pub node_operator_count: u32,
175    pub node_provider_count: u32,
176    pub node_count: u32,
177}
178
179///
180/// RegistryFetchError
181///
182#[derive(Debug, ThisError)]
183pub enum RegistryFetchError {
184    #[error("failed to build IC agent for {endpoint}: {reason}")]
185    AgentBuild { endpoint: String, reason: String },
186
187    #[error("registry agent call {method} failed: {reason}")]
188    AgentCall {
189        method: &'static str,
190        reason: String,
191    },
192
193    #[error("failed to encode protobuf {message}: {reason}")]
194    ProtobufEncode {
195        message: &'static str,
196        reason: String,
197    },
198
199    #[error("failed to decode protobuf {message}: {reason}")]
200    ProtobufDecode {
201        message: &'static str,
202        reason: String,
203    },
204
205    #[error("registry get_value for key {key} failed with code {code}: {reason}")]
206    RegistryValue {
207        key: String,
208        code: String,
209        reason: String,
210    },
211
212    #[error("registry get_value for key {key} returned no value content")]
213    MissingValue { key: String },
214
215    #[error("failed to encode candid {message}: {reason}")]
216    CandidEncode {
217        message: &'static str,
218        reason: String,
219    },
220
221    #[error("failed to decode candid {message}: {reason}")]
222    CandidDecode {
223        message: &'static str,
224        reason: String,
225    },
226
227    #[error("registry get_chunk for sha256 {sha256} failed: {reason}")]
228    RegistryChunkRejected { sha256: String, reason: String },
229
230    #[error("registry get_chunk for sha256 {sha256} returned no chunk content")]
231    MissingChunkContent { sha256: String },
232
233    #[error("registry get_chunk for sha256 {sha256} returned content with sha256 {actual_sha256}")]
234    ChunkHashMismatch {
235        sha256: String,
236        actual_sha256: String,
237    },
238
239    #[error("registry protobuf field {field} was missing")]
240    MissingField { field: &'static str },
241
242    #[error("registry principal field {field} is invalid: {reason}")]
243    InvalidPrincipal { field: &'static str, reason: String },
244
245    #[error("data center record id mismatch: key id {key_id}, record id {record_id}")]
246    InvalidDataCenterRecordId { key_id: String, record_id: String },
247
248    #[error("registry subnet list was empty")]
249    EmptySubnetList,
250
251    #[error("registry routing table was empty")]
252    EmptyRoutingTable,
253
254    #[error(transparent)]
255    Catalog(#[from] CatalogError),
256
257    #[error("failed to create Tokio runtime for registry refresh: {0}")]
258    Runtime(String),
259}
260
261///
262/// RegistryValueContent
263///
264#[derive(Debug)]
265enum RegistryValueContent {
266    Value(Vec<u8>),
267    LargeValueChunkKeys(LargeValueChunkKeys),
268}
269
270///
271/// RegistryGetChunkRequest
272///
273#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
274struct RegistryGetChunkRequest {
275    content_sha256: Option<Vec<u8>>,
276}
277
278///
279/// RegistryChunk
280///
281#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
282struct RegistryChunk {
283    content: Option<Vec<u8>>,
284}
285
286///
287/// ListNodeProvidersResponse
288///
289#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
290struct ListNodeProvidersResponse {
291    node_providers: Vec<GovernanceNodeProvider>,
292}
293
294///
295/// GovernanceNodeProvider
296///
297#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
298struct GovernanceNodeProvider {
299    id: Option<Principal>,
300    reward_account: Option<GovernanceAccountIdentifier>,
301}
302
303///
304/// GovernanceAccountIdentifier
305///
306#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
307struct GovernanceAccountIdentifier {
308    hash: Vec<u8>,
309}
310
311pub fn fetch_mainnet_subnet_catalog(
312    request: &MainnetRegistryFetchRequest,
313) -> Result<SubnetCatalog, RegistryFetchError> {
314    let runtime = tokio::runtime::Builder::new_current_thread()
315        .enable_all()
316        .build()
317        .map_err(|err| RegistryFetchError::Runtime(err.to_string()))?;
318    runtime.block_on(fetch_mainnet_subnet_catalog_async(request))
319}
320
321pub fn fetch_mainnet_registry_version(
322    request: &MainnetRegistryFetchRequest,
323) -> Result<MainnetRegistryVersion, RegistryFetchError> {
324    let runtime = tokio::runtime::Builder::new_current_thread()
325        .enable_all()
326        .build()
327        .map_err(|err| RegistryFetchError::Runtime(err.to_string()))?;
328    runtime.block_on(fetch_mainnet_registry_version_async(request))
329}
330
331pub fn fetch_mainnet_node_provider_list(
332    request: &MainnetRegistryFetchRequest,
333) -> Result<MainnetNodeProviderList, RegistryFetchError> {
334    let runtime = tokio::runtime::Builder::new_current_thread()
335        .enable_all()
336        .build()
337        .map_err(|err| RegistryFetchError::Runtime(err.to_string()))?;
338    runtime.block_on(fetch_mainnet_node_provider_list_async(request))
339}
340
341pub fn fetch_mainnet_node_operator_list(
342    request: &MainnetRegistryFetchRequest,
343) -> Result<MainnetNodeOperatorList, RegistryFetchError> {
344    let runtime = tokio::runtime::Builder::new_current_thread()
345        .enable_all()
346        .build()
347        .map_err(|err| RegistryFetchError::Runtime(err.to_string()))?;
348    runtime.block_on(fetch_mainnet_node_operator_list_async(request))
349}
350
351pub fn fetch_mainnet_node_list(
352    request: &MainnetRegistryFetchRequest,
353) -> Result<MainnetNodeList, RegistryFetchError> {
354    let runtime = tokio::runtime::Builder::new_current_thread()
355        .enable_all()
356        .build()
357        .map_err(|err| RegistryFetchError::Runtime(err.to_string()))?;
358    runtime.block_on(fetch_mainnet_node_list_async(request))
359}
360
361pub fn fetch_mainnet_data_center_list(
362    request: &MainnetRegistryFetchRequest,
363) -> Result<MainnetDataCenterList, RegistryFetchError> {
364    let runtime = tokio::runtime::Builder::new_current_thread()
365        .enable_all()
366        .build()
367        .map_err(|err| RegistryFetchError::Runtime(err.to_string()))?;
368    runtime.block_on(fetch_mainnet_data_center_list_async(request))
369}
370
371pub async fn fetch_mainnet_registry_version_async(
372    request: &MainnetRegistryFetchRequest,
373) -> Result<MainnetRegistryVersion, RegistryFetchError> {
374    let agent = Agent::builder()
375        .with_url(&request.endpoint)
376        .build()
377        .map_err(|err| RegistryFetchError::AgentBuild {
378            endpoint: request.endpoint.clone(),
379            reason: err.to_string(),
380        })?;
381    let registry_canister = Principal::from_text(MAINNET_REGISTRY_CANISTER_ID).map_err(|err| {
382        RegistryFetchError::InvalidPrincipal {
383            field: "registry_canister_id",
384            reason: err.to_string(),
385        }
386    })?;
387    let registry_version = get_latest_version(&agent, &registry_canister).await?;
388    Ok(MainnetRegistryVersion {
389        network: MAINNET_NETWORK.to_string(),
390        registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
391        registry_version,
392        fetched_at: request.fetched_at.clone(),
393        fetched_by: request.fetched_by.clone(),
394        source_endpoint: request.endpoint.clone(),
395    })
396}
397
398pub async fn fetch_mainnet_subnet_catalog_async(
399    request: &MainnetRegistryFetchRequest,
400) -> Result<SubnetCatalog, RegistryFetchError> {
401    let agent = Agent::builder()
402        .with_url(&request.endpoint)
403        .build()
404        .map_err(|err| RegistryFetchError::AgentBuild {
405            endpoint: request.endpoint.clone(),
406            reason: err.to_string(),
407        })?;
408    let registry_canister = Principal::from_text(MAINNET_REGISTRY_CANISTER_ID).map_err(|err| {
409        RegistryFetchError::InvalidPrincipal {
410            field: "registry_canister_id",
411            reason: err.to_string(),
412        }
413    })?;
414    let registry_version = get_latest_version(&agent, &registry_canister).await?;
415    let subnet_list_bytes = get_registry_value(
416        &agent,
417        &registry_canister,
418        SUBNET_LIST_KEY,
419        registry_version,
420    )
421    .await?;
422    let routing_table_bytes = get_registry_value(
423        &agent,
424        &registry_canister,
425        ROUTING_TABLE_KEY,
426        registry_version,
427    )
428    .await?;
429    let subnet_list = decode_message::<SubnetListRecord>("SubnetListRecord", &subnet_list_bytes)?;
430    let routing_table = decode_message::<RoutingTable>("RoutingTable", &routing_table_bytes)?;
431    catalog_from_registry_records(
432        request,
433        registry_version,
434        &agent,
435        &registry_canister,
436        subnet_list,
437        routing_table,
438    )
439    .await
440}
441
442pub async fn fetch_mainnet_node_provider_list_async(
443    request: &MainnetRegistryFetchRequest,
444) -> Result<MainnetNodeProviderList, RegistryFetchError> {
445    let agent = Agent::builder()
446        .with_url(&request.endpoint)
447        .build()
448        .map_err(|err| RegistryFetchError::AgentBuild {
449            endpoint: request.endpoint.clone(),
450            reason: err.to_string(),
451        })?;
452    let governance_canister =
453        Principal::from_text(MAINNET_GOVERNANCE_CANISTER_ID).map_err(|err| {
454            RegistryFetchError::InvalidPrincipal {
455                field: "governance_canister_id",
456                reason: err.to_string(),
457            }
458        })?;
459    let arg = Encode!().map_err(|err| RegistryFetchError::CandidEncode {
460        message: "list_node_providers",
461        reason: err.to_string(),
462    })?;
463    let bytes = agent
464        .query(&governance_canister, "list_node_providers")
465        .with_arg(arg)
466        .call()
467        .await
468        .map_err(|err| RegistryFetchError::AgentCall {
469            method: "list_node_providers",
470            reason: err.to_string(),
471        })?;
472    let response = Decode!(&bytes, ListNodeProvidersResponse).map_err(|err| {
473        RegistryFetchError::CandidDecode {
474            message: "ListNodeProvidersResponse",
475            reason: err.to_string(),
476        }
477    })?;
478    let registry_canister = Principal::from_text(MAINNET_REGISTRY_CANISTER_ID).map_err(|err| {
479        RegistryFetchError::InvalidPrincipal {
480            field: "registry_canister_id",
481            reason: err.to_string(),
482        }
483    })?;
484    let registry_version = get_latest_version(&agent, &registry_canister).await?;
485    let node_counts =
486        fetch_node_provider_node_counts(&agent, &registry_canister, registry_version).await?;
487    node_provider_list_from_response(request, response, node_counts, registry_version)
488}
489
490pub async fn fetch_mainnet_node_operator_list_async(
491    request: &MainnetRegistryFetchRequest,
492) -> Result<MainnetNodeOperatorList, RegistryFetchError> {
493    let agent = Agent::builder()
494        .with_url(&request.endpoint)
495        .build()
496        .map_err(|err| RegistryFetchError::AgentBuild {
497            endpoint: request.endpoint.clone(),
498            reason: err.to_string(),
499        })?;
500    let registry_canister = Principal::from_text(MAINNET_REGISTRY_CANISTER_ID).map_err(|err| {
501        RegistryFetchError::InvalidPrincipal {
502            field: "registry_canister_id",
503            reason: err.to_string(),
504        }
505    })?;
506    let registry_version = get_latest_version(&agent, &registry_canister).await?;
507    let inventory = fetch_registry_relation_inventory(
508        &agent,
509        &registry_canister,
510        registry_version,
511        RegistryRelationInventoryScope::BaseRelations,
512    )
513    .await?;
514    node_operator_list_from_inventory(request, inventory, registry_version)
515}
516
517pub async fn fetch_mainnet_node_list_async(
518    request: &MainnetRegistryFetchRequest,
519) -> Result<MainnetNodeList, RegistryFetchError> {
520    let agent = Agent::builder()
521        .with_url(&request.endpoint)
522        .build()
523        .map_err(|err| RegistryFetchError::AgentBuild {
524            endpoint: request.endpoint.clone(),
525            reason: err.to_string(),
526        })?;
527    let registry_canister = Principal::from_text(MAINNET_REGISTRY_CANISTER_ID).map_err(|err| {
528        RegistryFetchError::InvalidPrincipal {
529            field: "registry_canister_id",
530            reason: err.to_string(),
531        }
532    })?;
533    let registry_version = get_latest_version(&agent, &registry_canister).await?;
534    let inventory = fetch_registry_relation_inventory(
535        &agent,
536        &registry_canister,
537        registry_version,
538        RegistryRelationInventoryScope::BaseRelations,
539    )
540    .await?;
541    node_list_from_inventory(request, inventory, registry_version)
542}
543
544pub async fn fetch_mainnet_data_center_list_async(
545    request: &MainnetRegistryFetchRequest,
546) -> Result<MainnetDataCenterList, RegistryFetchError> {
547    let agent = Agent::builder()
548        .with_url(&request.endpoint)
549        .build()
550        .map_err(|err| RegistryFetchError::AgentBuild {
551            endpoint: request.endpoint.clone(),
552            reason: err.to_string(),
553        })?;
554    let registry_canister = Principal::from_text(MAINNET_REGISTRY_CANISTER_ID).map_err(|err| {
555        RegistryFetchError::InvalidPrincipal {
556            field: "registry_canister_id",
557            reason: err.to_string(),
558        }
559    })?;
560    let registry_version = get_latest_version(&agent, &registry_canister).await?;
561    let inventory = fetch_registry_relation_inventory(
562        &agent,
563        &registry_canister,
564        registry_version,
565        RegistryRelationInventoryScope::WithDataCenters,
566    )
567    .await?;
568    data_center_list_from_inventory(request, inventory, registry_version)
569}
570
571async fn catalog_from_registry_records(
572    request: &MainnetRegistryFetchRequest,
573    registry_version: u64,
574    agent: &Agent,
575    registry_canister: &Principal,
576    subnet_list: SubnetListRecord,
577    routing_table: RoutingTable,
578) -> Result<SubnetCatalog, RegistryFetchError> {
579    if subnet_list.subnets.is_empty() {
580        return Err(RegistryFetchError::EmptySubnetList);
581    }
582    if routing_table.entries.is_empty() {
583        return Err(RegistryFetchError::EmptyRoutingTable);
584    }
585
586    let mut subnets = Vec::with_capacity(subnet_list.subnets.len());
587    for subnet_raw in subnet_list.subnets {
588        let subnet_principal = principal_text_from_raw(&subnet_raw, "subnet_list.subnets")?;
589        let key = subnet_record_key(&subnet_principal);
590        let record_bytes =
591            get_registry_value(agent, registry_canister, &key, registry_version).await?;
592        let record = decode_message::<SubnetRecord>("SubnetRecord", &record_bytes)?;
593        subnets.push(subnet_info_from_record(&subnet_principal, &record));
594    }
595
596    subnets.sort_by(|left, right| left.subnet_principal.cmp(&right.subnet_principal));
597
598    let mut routing_ranges = routing_ranges_from_table(&routing_table)?;
599    routing_ranges.sort_by(|left, right| {
600        left.start_canister_id
601            .cmp(&right.start_canister_id)
602            .then_with(|| left.end_canister_id.cmp(&right.end_canister_id))
603            .then_with(|| left.subnet_principal.cmp(&right.subnet_principal))
604    });
605
606    let mut catalog = SubnetCatalog {
607        catalog_schema_version: CATALOG_SCHEMA_VERSION,
608        network: MAINNET_NETWORK.to_string(),
609        registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
610        registry_version,
611        fetched_at: request.fetched_at.clone(),
612        fetched_by: request.fetched_by.clone(),
613        source_endpoint: request.endpoint.clone(),
614        resolver_backend: "local-nns-subnet-catalog".to_string(),
615        subnets,
616        routing_ranges,
617    };
618    apply_mainnet_annotations(&mut catalog);
619    catalog.validate()?;
620    Ok(catalog)
621}
622
623fn node_provider_list_from_response(
624    request: &MainnetRegistryFetchRequest,
625    response: ListNodeProvidersResponse,
626    node_counts: BTreeMap<String, u32>,
627    registry_version: u64,
628) -> Result<MainnetNodeProviderList, RegistryFetchError> {
629    let mut node_providers = response
630        .node_providers
631        .into_iter()
632        .map(|node_provider| node_provider_from_governance(node_provider, &node_counts))
633        .collect::<Result<Vec<_>, _>>()?;
634    node_providers.sort_by(|left, right| left.principal.cmp(&right.principal));
635    Ok(MainnetNodeProviderList {
636        network: MAINNET_NETWORK.to_string(),
637        governance_canister_id: MAINNET_GOVERNANCE_CANISTER_ID.to_string(),
638        registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
639        registry_version,
640        fetched_at: request.fetched_at.clone(),
641        fetched_by: request.fetched_by.clone(),
642        source_endpoint: request.endpoint.clone(),
643        node_providers,
644    })
645}
646
647fn node_provider_from_governance(
648    node_provider: GovernanceNodeProvider,
649    node_counts: &BTreeMap<String, u32>,
650) -> Result<MainnetNodeProvider, RegistryFetchError> {
651    let principal = node_provider
652        .id
653        .ok_or(RegistryFetchError::MissingField {
654            field: "node_provider.id",
655        })?
656        .to_text();
657    let reward_account_hex = node_provider
658        .reward_account
659        .map(|account| hex_bytes(&account.hash));
660    let node_count = Some(node_counts.get(&principal).copied().unwrap_or(0));
661    Ok(MainnetNodeProvider {
662        principal,
663        node_count,
664        reward_account_hex,
665    })
666}
667
668async fn fetch_node_provider_node_counts(
669    agent: &Agent,
670    registry_canister: &Principal,
671    registry_version: u64,
672) -> Result<BTreeMap<String, u32>, RegistryFetchError> {
673    let inventory = fetch_registry_relation_inventory(
674        agent,
675        registry_canister,
676        registry_version,
677        RegistryRelationInventoryScope::BaseRelations,
678    )
679    .await?;
680    node_provider_counts_from_records(
681        &inventory.node_principals,
682        &inventory.node_records,
683        &inventory.node_operator_records,
684    )
685}
686
687async fn fetch_registry_relation_inventory(
688    agent: &Agent,
689    registry_canister: &Principal,
690    registry_version: u64,
691    scope: RegistryRelationInventoryScope,
692) -> Result<RegistryRelationInventory, RegistryFetchError> {
693    let subnet_list_bytes =
694        get_registry_value(agent, registry_canister, SUBNET_LIST_KEY, registry_version).await?;
695    let subnet_list = decode_message::<SubnetListRecord>("SubnetListRecord", &subnet_list_bytes)?;
696    if subnet_list.subnets.is_empty() {
697        return Err(RegistryFetchError::EmptySubnetList);
698    }
699
700    let subnet_principals = subnet_list
701        .subnets
702        .iter()
703        .map(|subnet_raw| principal_text_from_raw(subnet_raw, "subnet_list.subnets"))
704        .collect::<Result<Vec<_>, _>>()?;
705    let subnet_records = stream::iter(subnet_principals)
706        .map(|subnet_principal| async move {
707            let key = subnet_record_key(&subnet_principal);
708            let record_bytes =
709                get_registry_value(agent, registry_canister, &key, registry_version).await?;
710            let record = decode_message::<SubnetRecord>("SubnetRecord", &record_bytes)?;
711            Ok::<_, RegistryFetchError>((subnet_principal, record))
712        })
713        .buffer_unordered(NODE_PROVIDER_ENRICHMENT_CONCURRENCY)
714        .try_collect::<BTreeMap<_, _>>()
715        .await?;
716
717    let node_principals = assigned_node_principals_from_subnets(&subnet_records)?;
718    let node_records = stream::iter(node_principals.iter().cloned())
719        .map(|node_principal| async move {
720            let key = node_record_key(&node_principal);
721            let record_bytes =
722                get_registry_value(agent, registry_canister, &key, registry_version).await?;
723            let record = decode_message::<NodeRecord>("NodeRecord", &record_bytes)?;
724            Ok::<_, RegistryFetchError>((node_principal, record))
725        })
726        .buffer_unordered(NODE_PROVIDER_ENRICHMENT_CONCURRENCY)
727        .try_collect::<BTreeMap<_, _>>()
728        .await?;
729
730    let mut node_operator_principals = BTreeSet::new();
731    for record in node_records.values() {
732        node_operator_principals.insert(principal_text_from_required_raw(
733            &record.node_operator_id,
734            "node_record.node_operator_id",
735        )?);
736    }
737
738    let node_operator_records = stream::iter(node_operator_principals)
739        .map(|node_operator_principal| async move {
740            let key = node_operator_record_key(&node_operator_principal);
741            let record_bytes =
742                get_registry_value(agent, registry_canister, &key, registry_version).await?;
743            let record = decode_message::<NodeOperatorRecord>("NodeOperatorRecord", &record_bytes)?;
744            Ok::<_, RegistryFetchError>((node_operator_principal, record))
745        })
746        .buffer_unordered(NODE_PROVIDER_ENRICHMENT_CONCURRENCY)
747        .try_collect::<BTreeMap<_, _>>()
748        .await?;
749
750    let data_center_records = match scope {
751        RegistryRelationInventoryScope::BaseRelations => BTreeMap::new(),
752        RegistryRelationInventoryScope::WithDataCenters => {
753            fetch_data_center_records_for_inventory(
754                agent,
755                registry_canister,
756                registry_version,
757                &node_operator_records,
758            )
759            .await?
760        }
761    };
762
763    Ok(RegistryRelationInventory {
764        node_principals,
765        node_records,
766        node_operator_records,
767        subnet_records,
768        data_center_records,
769    })
770}
771
772async fn fetch_data_center_records_for_inventory(
773    agent: &Agent,
774    registry_canister: &Principal,
775    registry_version: u64,
776    node_operator_records: &BTreeMap<String, NodeOperatorRecord>,
777) -> Result<BTreeMap<String, DataCenterRecord>, RegistryFetchError> {
778    let data_center_ids = node_operator_records
779        .values()
780        .filter_map(|record| normalized_data_center_id(&record.dc_id))
781        .collect::<BTreeSet<_>>();
782    stream::iter(data_center_ids)
783        .map(|data_center_id| async move {
784            let key = data_center_record_key(&data_center_id);
785            let record_bytes =
786                get_registry_value(agent, registry_canister, &key, registry_version).await?;
787            let record = decode_message::<DataCenterRecord>("DataCenterRecord", &record_bytes)?;
788            Ok::<_, RegistryFetchError>((data_center_id, record))
789        })
790        .buffer_unordered(NODE_PROVIDER_ENRICHMENT_CONCURRENCY)
791        .try_collect::<BTreeMap<_, _>>()
792        .await
793}
794
795fn node_operator_list_from_inventory(
796    request: &MainnetRegistryFetchRequest,
797    inventory: RegistryRelationInventory,
798    registry_version: u64,
799) -> Result<MainnetNodeOperatorList, RegistryFetchError> {
800    let node_counts = node_operator_counts_from_records(
801        &inventory.node_principals,
802        &inventory.node_records,
803        &inventory.node_operator_records,
804    )?;
805    let mut node_operators = inventory
806        .node_operator_records
807        .into_iter()
808        .map(|(principal, record)| node_operator_from_record(principal, record, &node_counts))
809        .collect::<Result<Vec<_>, _>>()?;
810    node_operators.sort_by(|left, right| left.principal.cmp(&right.principal));
811    Ok(MainnetNodeOperatorList {
812        network: MAINNET_NETWORK.to_string(),
813        registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
814        registry_version,
815        fetched_at: request.fetched_at.clone(),
816        fetched_by: request.fetched_by.clone(),
817        source_endpoint: request.endpoint.clone(),
818        node_operators,
819    })
820}
821
822fn node_operator_from_record(
823    principal: String,
824    record: NodeOperatorRecord,
825    node_counts: &BTreeMap<String, u32>,
826) -> Result<MainnetNodeOperator, RegistryFetchError> {
827    let node_provider_principal = principal_text_from_required_raw(
828        &record.node_provider_principal_id,
829        "node_operator_record.node_provider_principal_id",
830    )?;
831    Ok(MainnetNodeOperator {
832        node_count: Some(node_counts.get(&principal).copied().unwrap_or(0)),
833        principal,
834        node_provider_principal,
835        node_allowance: record.node_allowance,
836        data_center_id: record.dc_id,
837    })
838}
839
840fn node_list_from_inventory(
841    request: &MainnetRegistryFetchRequest,
842    inventory: RegistryRelationInventory,
843    registry_version: u64,
844) -> Result<MainnetNodeList, RegistryFetchError> {
845    let node_subnets = node_subnet_assignments_from_records(&inventory.subnet_records)?;
846    let mut nodes = inventory
847        .node_records
848        .into_iter()
849        .map(|(principal, record)| {
850            node_from_record(
851                principal,
852                record,
853                &inventory.node_operator_records,
854                &inventory.subnet_records,
855                &node_subnets,
856            )
857        })
858        .collect::<Result<Vec<_>, _>>()?;
859    nodes.sort_by(|left, right| left.principal.cmp(&right.principal));
860    Ok(MainnetNodeList {
861        network: MAINNET_NETWORK.to_string(),
862        registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
863        registry_version,
864        fetched_at: request.fetched_at.clone(),
865        fetched_by: request.fetched_by.clone(),
866        source_endpoint: request.endpoint.clone(),
867        nodes,
868    })
869}
870
871fn node_from_record(
872    principal: String,
873    record: NodeRecord,
874    node_operator_records: &BTreeMap<String, NodeOperatorRecord>,
875    subnet_records: &BTreeMap<String, SubnetRecord>,
876    node_subnets: &BTreeMap<String, String>,
877) -> Result<MainnetNode, RegistryFetchError> {
878    let node_operator_principal =
879        principal_text_from_required_raw(&record.node_operator_id, "node_record.node_operator_id")?;
880    let node_operator_record = node_operator_records.get(&node_operator_principal).ok_or(
881        RegistryFetchError::MissingField {
882            field: "node_operator_record",
883        },
884    )?;
885    let node_provider_principal = principal_text_from_required_raw(
886        &node_operator_record.node_provider_principal_id,
887        "node_operator_record.node_provider_principal_id",
888    )?;
889    let subnet_principal =
890        node_subnets
891            .get(&principal)
892            .ok_or(RegistryFetchError::MissingField {
893                field: "node_subnet_assignment",
894            })?;
895    let subnet_record =
896        subnet_records
897            .get(subnet_principal)
898            .ok_or(RegistryFetchError::MissingField {
899                field: "subnet_record",
900            })?;
901    Ok(MainnetNode {
902        principal,
903        node_operator_principal,
904        node_provider_principal,
905        subnet_principal: subnet_principal.clone(),
906        subnet_kind: subnet_kind_text(subnet_record),
907        data_center_id: node_operator_record.dc_id.clone(),
908    })
909}
910
911fn data_center_list_from_inventory(
912    request: &MainnetRegistryFetchRequest,
913    inventory: RegistryRelationInventory,
914    registry_version: u64,
915) -> Result<MainnetDataCenterList, RegistryFetchError> {
916    let node_counts = data_center_node_counts_from_records(
917        &inventory.node_principals,
918        &inventory.node_records,
919        &inventory.node_operator_records,
920    )?;
921    let operator_counts =
922        data_center_operator_counts_from_records(&inventory.node_operator_records);
923    let provider_counts =
924        data_center_provider_counts_from_records(&inventory.node_operator_records)?;
925    let mut data_centers = inventory
926        .data_center_records
927        .into_iter()
928        .map(|(id, record)| {
929            data_center_from_record(id, record, &operator_counts, &provider_counts, &node_counts)
930        })
931        .collect::<Result<Vec<_>, _>>()?;
932    data_centers.sort_by(|left, right| left.id.cmp(&right.id));
933    Ok(MainnetDataCenterList {
934        network: MAINNET_NETWORK.to_string(),
935        registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
936        registry_version,
937        fetched_at: request.fetched_at.clone(),
938        fetched_by: request.fetched_by.clone(),
939        source_endpoint: request.endpoint.clone(),
940        data_centers,
941    })
942}
943
944fn data_center_from_record(
945    id: String,
946    record: DataCenterRecord,
947    operator_counts: &BTreeMap<String, u32>,
948    provider_counts: &BTreeMap<String, u32>,
949    node_counts: &BTreeMap<String, u32>,
950) -> Result<MainnetDataCenter, RegistryFetchError> {
951    if !record.id.is_empty() && normalized_data_center_id(&record.id).as_deref() != Some(&id) {
952        return Err(RegistryFetchError::InvalidDataCenterRecordId {
953            key_id: id,
954            record_id: record.id,
955        });
956    }
957    Ok(MainnetDataCenter {
958        node_operator_count: operator_counts.get(&id).copied().unwrap_or(0),
959        node_provider_count: provider_counts.get(&id).copied().unwrap_or(0),
960        node_count: node_counts.get(&id).copied().unwrap_or(0),
961        id,
962        region: record.region,
963        owner: record.owner,
964        latitude: record.gps.as_ref().map(|gps| gps.latitude),
965        longitude: record.gps.as_ref().map(|gps| gps.longitude),
966    })
967}
968
969async fn get_latest_version(
970    agent: &Agent,
971    registry_canister: &Principal,
972) -> Result<u64, RegistryFetchError> {
973    let bytes = agent
974        .query(registry_canister, "get_latest_version")
975        .with_arg(Vec::<u8>::new())
976        .call()
977        .await
978        .map_err(|err| RegistryFetchError::AgentCall {
979            method: "get_latest_version",
980            reason: err.to_string(),
981        })?;
982    let response = decode_message::<RegistryGetLatestVersionResponse>(
983        "RegistryGetLatestVersionResponse",
984        &bytes,
985    )?;
986    Ok(response.version)
987}
988
989async fn get_registry_value(
990    agent: &Agent,
991    registry_canister: &Principal,
992    key: &str,
993    version: u64,
994) -> Result<Vec<u8>, RegistryFetchError> {
995    let request = RegistryGetValueRequest {
996        version: Some(UInt64Value { value: version }),
997        key: key.as_bytes().to_vec(),
998    };
999    let mut arg = Vec::new();
1000    request
1001        .encode(&mut arg)
1002        .map_err(|err| RegistryFetchError::ProtobufEncode {
1003            message: "RegistryGetValueRequest",
1004            reason: err.to_string(),
1005        })?;
1006    let bytes = agent
1007        .query(registry_canister, "get_value")
1008        .with_arg(arg)
1009        .call()
1010        .await
1011        .map_err(|err| RegistryFetchError::AgentCall {
1012            method: "get_value",
1013            reason: err.to_string(),
1014        })?;
1015    let response = decode_message::<RegistryGetValueResponse>("RegistryGetValueResponse", &bytes)?;
1016    match registry_value_content_from_response(key, response)? {
1017        RegistryValueContent::Value(value) => Ok(value),
1018        RegistryValueContent::LargeValueChunkKeys(keys) => {
1019            get_large_registry_value(agent, registry_canister, &keys).await
1020        }
1021    }
1022}
1023
1024fn registry_value_content_from_response(
1025    key: &str,
1026    response: RegistryGetValueResponse,
1027) -> Result<RegistryValueContent, RegistryFetchError> {
1028    if let Some(error) = response.error {
1029        return Err(RegistryFetchError::RegistryValue {
1030            key: key.to_string(),
1031            code: registry_error_code(error.code).to_string(),
1032            reason: error.reason,
1033        });
1034    }
1035    match response.content {
1036        Some(registry_get_value_response::Content::Value(value)) => {
1037            Ok(RegistryValueContent::Value(value))
1038        }
1039        Some(registry_get_value_response::Content::LargeValueChunkKeys(keys)) => {
1040            Ok(RegistryValueContent::LargeValueChunkKeys(keys))
1041        }
1042        None => Err(RegistryFetchError::MissingValue {
1043            key: key.to_string(),
1044        }),
1045    }
1046}
1047
1048async fn get_large_registry_value(
1049    agent: &Agent,
1050    registry_canister: &Principal,
1051    keys: &LargeValueChunkKeys,
1052) -> Result<Vec<u8>, RegistryFetchError> {
1053    let mut value = Vec::new();
1054    for chunk_sha256 in &keys.chunk_content_sha256s {
1055        let chunk_content = get_registry_chunk(agent, registry_canister, chunk_sha256).await?;
1056        append_validated_chunk(&mut value, chunk_sha256, chunk_content)?;
1057    }
1058    Ok(value)
1059}
1060
1061async fn get_registry_chunk(
1062    agent: &Agent,
1063    registry_canister: &Principal,
1064    content_sha256: &[u8],
1065) -> Result<Vec<u8>, RegistryFetchError> {
1066    let request = RegistryGetChunkRequest {
1067        content_sha256: Some(content_sha256.to_vec()),
1068    };
1069    let arg = Encode!(&request).map_err(|err| RegistryFetchError::CandidEncode {
1070        message: "RegistryGetChunkRequest",
1071        reason: err.to_string(),
1072    })?;
1073    let bytes = agent
1074        .query(registry_canister, "get_chunk")
1075        .with_arg(arg)
1076        .call()
1077        .await
1078        .map_err(|err| RegistryFetchError::AgentCall {
1079            method: "get_chunk",
1080            reason: err.to_string(),
1081        })?;
1082    let result = Decode!(&bytes, Result<RegistryChunk, String>).map_err(|err| {
1083        RegistryFetchError::CandidDecode {
1084            message: "Result<RegistryChunk, String>",
1085            reason: err.to_string(),
1086        }
1087    })?;
1088    match result {
1089        Ok(chunk) => chunk
1090            .content
1091            .ok_or_else(|| RegistryFetchError::MissingChunkContent {
1092                sha256: hex_bytes(content_sha256),
1093            }),
1094        Err(reason) => Err(RegistryFetchError::RegistryChunkRejected {
1095            sha256: hex_bytes(content_sha256),
1096            reason,
1097        }),
1098    }
1099}
1100
1101fn append_validated_chunk(
1102    value: &mut Vec<u8>,
1103    expected_sha256: &[u8],
1104    chunk_content: Vec<u8>,
1105) -> Result<(), RegistryFetchError> {
1106    let actual_sha256 = sha256_digest(&chunk_content);
1107    if actual_sha256.as_slice() != expected_sha256 {
1108        return Err(RegistryFetchError::ChunkHashMismatch {
1109            sha256: hex_bytes(expected_sha256),
1110            actual_sha256: hex_bytes(&actual_sha256),
1111        });
1112    }
1113    value.extend(chunk_content);
1114    Ok(())
1115}
1116
1117fn sha256_digest(bytes: &[u8]) -> [u8; 32] {
1118    Sha256::digest(bytes).into()
1119}
1120
1121fn hex_bytes(bytes: &[u8]) -> String {
1122    const HEX: &[u8; 16] = b"0123456789abcdef";
1123    let mut out = String::with_capacity(bytes.len() * 2);
1124    for byte in bytes {
1125        out.push(HEX[usize::from(byte >> 4)] as char);
1126        out.push(HEX[usize::from(byte & 0x0f)] as char);
1127    }
1128    out
1129}
1130
1131fn decode_message<T>(message: &'static str, bytes: &[u8]) -> Result<T, RegistryFetchError>
1132where
1133    T: Message + Default,
1134{
1135    T::decode(bytes).map_err(|err| RegistryFetchError::ProtobufDecode {
1136        message,
1137        reason: err.to_string(),
1138    })
1139}
1140
1141fn subnet_info_from_record(subnet_principal: &str, record: &SubnetRecord) -> SubnetInfo {
1142    let subnet_kind = match SubnetType::try_from(record.subnet_type).ok() {
1143        Some(SubnetType::Application | SubnetType::VerifiedApplication) => SubnetKind::Application,
1144        Some(SubnetType::CloudEngine) => SubnetKind::CloudEngine,
1145        Some(SubnetType::System) => SubnetKind::System,
1146        Some(SubnetType::Unspecified) | None => SubnetKind::Unknown,
1147    };
1148    let charges_apply_by_default = subnet_kind.charges_apply_by_default();
1149    SubnetInfo {
1150        subnet_principal: subnet_principal.to_string(),
1151        subnet_kind,
1152        subnet_kind_source: ClassificationSource::Registry,
1153        subnet_specialization: SubnetSpecialization::None,
1154        subnet_specialization_source: ClassificationSource::Computed,
1155        geographic_scope: GeographicScope::Global,
1156        geographic_scope_source: ClassificationSource::Computed,
1157        subnet_label: subnet_kind.as_str().to_string(),
1158        subnet_label_source: ClassificationSource::Computed,
1159        node_count: Some(u32::try_from(record.membership.len()).unwrap_or(u32::MAX)),
1160        charges_apply_by_default,
1161    }
1162}
1163
1164fn routing_ranges_from_table(
1165    table: &RoutingTable,
1166) -> Result<Vec<RoutingRange>, RegistryFetchError> {
1167    table
1168        .entries
1169        .iter()
1170        .map(|entry| {
1171            let range = entry
1172                .range
1173                .as_ref()
1174                .ok_or(RegistryFetchError::MissingField {
1175                    field: "routing_table.entries.range",
1176                })?;
1177            let subnet_id = entry
1178                .subnet_id
1179                .as_ref()
1180                .ok_or(RegistryFetchError::MissingField {
1181                    field: "routing_table.entries.subnet_id",
1182                })?;
1183            Ok(RoutingRange {
1184                start_canister_id: canister_id_text(
1185                    range.start_canister_id.as_ref(),
1186                    "range.start",
1187                )?,
1188                end_canister_id: canister_id_text(range.end_canister_id.as_ref(), "range.end")?,
1189                subnet_principal: subnet_id_text(subnet_id)?,
1190            })
1191        })
1192        .collect()
1193}
1194
1195fn canister_id_text(
1196    canister_id: Option<&CanisterId>,
1197    field: &'static str,
1198) -> Result<String, RegistryFetchError> {
1199    let principal = canister_id
1200        .and_then(|id| id.principal_id.as_ref())
1201        .ok_or(RegistryFetchError::MissingField { field })?;
1202    principal_text_from_raw(&principal.raw, field)
1203}
1204
1205fn subnet_id_text(subnet_id: &SubnetId) -> Result<String, RegistryFetchError> {
1206    let principal = subnet_id
1207        .principal_id
1208        .as_ref()
1209        .ok_or(RegistryFetchError::MissingField {
1210            field: "routing_table.entries.subnet_id.principal_id",
1211        })?;
1212    principal_text_from_raw(&principal.raw, "routing_table.entries.subnet_id")
1213}
1214
1215fn principal_text_from_raw(raw: &[u8], field: &'static str) -> Result<String, RegistryFetchError> {
1216    Principal::try_from_slice(raw)
1217        .map(|principal| principal.to_text())
1218        .map_err(|err| RegistryFetchError::InvalidPrincipal {
1219            field,
1220            reason: err.to_string(),
1221        })
1222}
1223
1224fn principal_text_from_required_raw(
1225    raw: &[u8],
1226    field: &'static str,
1227) -> Result<String, RegistryFetchError> {
1228    if raw.is_empty() {
1229        return Err(RegistryFetchError::MissingField { field });
1230    }
1231    principal_text_from_raw(raw, field)
1232}
1233
1234fn subnet_record_key(subnet_principal: &str) -> String {
1235    format!("{SUBNET_RECORD_KEY_PREFIX}{subnet_principal}")
1236}
1237
1238fn node_record_key(node_principal: &str) -> String {
1239    format!("{NODE_RECORD_KEY_PREFIX}{node_principal}")
1240}
1241
1242fn node_operator_record_key(node_operator_principal: &str) -> String {
1243    format!("{NODE_OPERATOR_RECORD_KEY_PREFIX}{node_operator_principal}")
1244}
1245
1246fn data_center_record_key(data_center_id: &str) -> String {
1247    format!("{DATA_CENTER_RECORD_KEY_PREFIX}{data_center_id}")
1248}
1249
1250fn normalized_data_center_id(data_center_id: &str) -> Option<String> {
1251    let trimmed = data_center_id.trim();
1252    if trimmed.is_empty() {
1253        None
1254    } else {
1255        Some(trimmed.to_ascii_lowercase())
1256    }
1257}
1258
1259fn assigned_node_principals_from_subnets(
1260    subnet_records: &BTreeMap<String, SubnetRecord>,
1261) -> Result<BTreeSet<String>, RegistryFetchError> {
1262    let mut node_principals = BTreeSet::new();
1263    for record in subnet_records.values() {
1264        for raw in &record.membership {
1265            node_principals.insert(principal_text_from_raw(raw, "subnet_record.membership")?);
1266        }
1267    }
1268    Ok(node_principals)
1269}
1270
1271fn node_subnet_assignments_from_records(
1272    subnet_records: &BTreeMap<String, SubnetRecord>,
1273) -> Result<BTreeMap<String, String>, RegistryFetchError> {
1274    let mut assignments = BTreeMap::new();
1275    for (subnet_principal, record) in subnet_records {
1276        for raw in &record.membership {
1277            let node_principal = principal_text_from_raw(raw, "subnet_record.membership")?;
1278            assignments.insert(node_principal, subnet_principal.clone());
1279        }
1280    }
1281    Ok(assignments)
1282}
1283
1284fn node_provider_counts_from_records(
1285    node_principals: &BTreeSet<String>,
1286    node_records: &BTreeMap<String, NodeRecord>,
1287    node_operator_records: &BTreeMap<String, NodeOperatorRecord>,
1288) -> Result<BTreeMap<String, u32>, RegistryFetchError> {
1289    let mut counts = BTreeMap::<String, u32>::new();
1290    for relation in assigned_node_relations(node_principals, node_records, node_operator_records)? {
1291        let count = counts.entry(relation.node_provider_principal).or_default();
1292        *count = count.saturating_add(1);
1293    }
1294    Ok(counts)
1295}
1296
1297fn node_operator_counts_from_records(
1298    node_principals: &BTreeSet<String>,
1299    node_records: &BTreeMap<String, NodeRecord>,
1300    node_operator_records: &BTreeMap<String, NodeOperatorRecord>,
1301) -> Result<BTreeMap<String, u32>, RegistryFetchError> {
1302    let mut counts = BTreeMap::<String, u32>::new();
1303    for relation in assigned_node_relations(node_principals, node_records, node_operator_records)? {
1304        let count = counts.entry(relation.node_operator_principal).or_default();
1305        *count = count.saturating_add(1);
1306    }
1307    Ok(counts)
1308}
1309
1310fn data_center_node_counts_from_records(
1311    node_principals: &BTreeSet<String>,
1312    node_records: &BTreeMap<String, NodeRecord>,
1313    node_operator_records: &BTreeMap<String, NodeOperatorRecord>,
1314) -> Result<BTreeMap<String, u32>, RegistryFetchError> {
1315    let mut counts = BTreeMap::<String, u32>::new();
1316    for relation in assigned_node_relations(node_principals, node_records, node_operator_records)? {
1317        if let Some(data_center_id) = relation.data_center_id {
1318            let count = counts.entry(data_center_id).or_default();
1319            *count = count.saturating_add(1);
1320        }
1321    }
1322    Ok(counts)
1323}
1324
1325fn assigned_node_relations(
1326    node_principals: &BTreeSet<String>,
1327    node_records: &BTreeMap<String, NodeRecord>,
1328    node_operator_records: &BTreeMap<String, NodeOperatorRecord>,
1329) -> Result<Vec<AssignedNodeRelation>, RegistryFetchError> {
1330    let mut relations = Vec::with_capacity(node_principals.len());
1331    for node_principal in node_principals {
1332        let node_record =
1333            node_records
1334                .get(node_principal)
1335                .ok_or(RegistryFetchError::MissingField {
1336                    field: "node_record",
1337                })?;
1338        let node_operator_principal = principal_text_from_required_raw(
1339            &node_record.node_operator_id,
1340            "node_record.node_operator_id",
1341        )?;
1342        let node_operator_record = node_operator_records.get(&node_operator_principal).ok_or(
1343            RegistryFetchError::MissingField {
1344                field: "node_operator_record",
1345            },
1346        )?;
1347        let node_provider_principal = principal_text_from_required_raw(
1348            &node_operator_record.node_provider_principal_id,
1349            "node_operator_record.node_provider_principal_id",
1350        )?;
1351        relations.push(AssignedNodeRelation {
1352            node_operator_principal,
1353            node_provider_principal,
1354            data_center_id: normalized_data_center_id(&node_operator_record.dc_id),
1355        });
1356    }
1357    Ok(relations)
1358}
1359
1360fn data_center_operator_counts_from_records(
1361    node_operator_records: &BTreeMap<String, NodeOperatorRecord>,
1362) -> BTreeMap<String, u32> {
1363    let mut counts = BTreeMap::<String, u32>::new();
1364    for record in node_operator_records.values() {
1365        if let Some(data_center_id) = normalized_data_center_id(&record.dc_id) {
1366            let count = counts.entry(data_center_id).or_default();
1367            *count = count.saturating_add(1);
1368        }
1369    }
1370    counts
1371}
1372
1373fn data_center_provider_counts_from_records(
1374    node_operator_records: &BTreeMap<String, NodeOperatorRecord>,
1375) -> Result<BTreeMap<String, u32>, RegistryFetchError> {
1376    let mut providers_by_data_center = BTreeMap::<String, BTreeSet<String>>::new();
1377    for record in node_operator_records.values() {
1378        let Some(data_center_id) = normalized_data_center_id(&record.dc_id) else {
1379            continue;
1380        };
1381        let node_provider_principal = principal_text_from_required_raw(
1382            &record.node_provider_principal_id,
1383            "node_operator_record.node_provider_principal_id",
1384        )?;
1385        providers_by_data_center
1386            .entry(data_center_id)
1387            .or_default()
1388            .insert(node_provider_principal);
1389    }
1390    Ok(providers_by_data_center
1391        .into_iter()
1392        .map(|(data_center_id, providers)| {
1393            (
1394                data_center_id,
1395                u32::try_from(providers.len()).unwrap_or(u32::MAX),
1396            )
1397        })
1398        .collect())
1399}
1400
1401///
1402/// RegistryRelationInventory
1403///
1404struct RegistryRelationInventory {
1405    node_principals: BTreeSet<String>,
1406    node_records: BTreeMap<String, NodeRecord>,
1407    node_operator_records: BTreeMap<String, NodeOperatorRecord>,
1408    subnet_records: BTreeMap<String, SubnetRecord>,
1409    data_center_records: BTreeMap<String, DataCenterRecord>,
1410}
1411
1412///
1413/// RegistryRelationInventoryScope
1414///
1415#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1416enum RegistryRelationInventoryScope {
1417    BaseRelations,
1418    WithDataCenters,
1419}
1420
1421///
1422/// AssignedNodeRelation
1423///
1424struct AssignedNodeRelation {
1425    node_operator_principal: String,
1426    node_provider_principal: String,
1427    data_center_id: Option<String>,
1428}
1429
1430fn subnet_kind_text(record: &SubnetRecord) -> String {
1431    match SubnetType::try_from(record.subnet_type).ok() {
1432        Some(SubnetType::Application | SubnetType::VerifiedApplication) => "application",
1433        Some(SubnetType::CloudEngine) => "cloud_engine",
1434        Some(SubnetType::System) => "system",
1435        Some(SubnetType::Unspecified) | None => "unknown",
1436    }
1437    .to_string()
1438}
1439
1440fn apply_mainnet_annotations(catalog: &mut SubnetCatalog) {
1441    let annotations = mainnet_annotations();
1442    for subnet in &mut catalog.subnets {
1443        let Some(annotation) = annotations.get(subnet.subnet_principal.as_str()) else {
1444            continue;
1445        };
1446        subnet.subnet_specialization = annotation.specialization;
1447        subnet.subnet_specialization_source = ClassificationSource::Curated;
1448        subnet.geographic_scope = annotation.geographic_scope;
1449        subnet.geographic_scope_source = ClassificationSource::Curated;
1450        subnet.subnet_label.clone_from(&annotation.label);
1451        subnet.subnet_label_source = ClassificationSource::Curated;
1452    }
1453}
1454
1455fn mainnet_annotations() -> BTreeMap<&'static str, MainnetAnnotation> {
1456    BTreeMap::from([
1457        (
1458            FIDUCIARY_SUBNET,
1459            MainnetAnnotation {
1460                specialization: SubnetSpecialization::Fiduciary,
1461                geographic_scope: GeographicScope::Global,
1462                label: "fiduciary".to_string(),
1463            },
1464        ),
1465        (
1466            EUROPEAN_SUBNET,
1467            MainnetAnnotation {
1468                specialization: SubnetSpecialization::European,
1469                geographic_scope: GeographicScope::Europe,
1470                label: "european".to_string(),
1471            },
1472        ),
1473    ])
1474}
1475
1476///
1477/// MainnetAnnotation
1478///
1479#[derive(Clone, Debug, Eq, PartialEq)]
1480struct MainnetAnnotation {
1481    specialization: SubnetSpecialization,
1482    geographic_scope: GeographicScope,
1483    label: String,
1484}
1485
1486fn registry_error_code(code: i32) -> &'static str {
1487    match RegistryErrorCode::try_from(code).ok() {
1488        Some(RegistryErrorCode::MalformedMessage) => "malformed_message",
1489        Some(RegistryErrorCode::KeyNotPresent) => "key_not_present",
1490        Some(RegistryErrorCode::KeyAlreadyPresent) => "key_already_present",
1491        Some(RegistryErrorCode::VersionNotLatest) => "version_not_latest",
1492        Some(RegistryErrorCode::VersionBeyondLatest) => "version_beyond_latest",
1493        Some(RegistryErrorCode::Authorization) => "authorization",
1494        Some(RegistryErrorCode::InternalError) => "internal_error",
1495        None => "unknown",
1496    }
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501    use super::*;
1502    use proto::{CanisterIdRange, PrincipalId, RoutingTableEntry};
1503
1504    const SUBNET_A: &str = "pzp6e-ekpqk-3c5x7-2h6so-njoeq-mt45d-h3h6c-q3mxf-vpeq5-fk5o7-yae";
1505    const SUBNET_B: &str = "aaaaa-aa";
1506    const CANISTER_A: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
1507
1508    #[test]
1509    fn registry_records_convert_to_catalog_domain_structs() {
1510        let request = MainnetRegistryFetchRequest {
1511            endpoint: "https://icp-api.io".to_string(),
1512            fetched_at: "2026-06-04T00:00:00Z".to_string(),
1513            fetched_by: "test".to_string(),
1514        };
1515        let subnet_records = BTreeMap::from([
1516            (
1517                SUBNET_A.to_string(),
1518                subnet_record(SubnetType::Application, 34),
1519            ),
1520            (SUBNET_B.to_string(), subnet_record(SubnetType::System, 13)),
1521        ]);
1522        let catalog = catalog_from_parts_for_test(
1523            &request,
1524            42,
1525            subnet_list_record([SUBNET_A, SUBNET_B]),
1526            routing_table_record([(CANISTER_A, CANISTER_A, SUBNET_A)]),
1527            subnet_records,
1528        )
1529        .expect("catalog");
1530
1531        assert_eq!(catalog.registry_version, 42);
1532        assert_eq!(catalog.subnets.len(), 2);
1533        assert_eq!(catalog.routing_ranges.len(), 1);
1534        let fiduciary = catalog.subnet_by_principal(SUBNET_A).expect("fiduciary");
1535        assert_eq!(
1536            fiduciary.subnet_specialization,
1537            SubnetSpecialization::Fiduciary
1538        );
1539        assert_eq!(fiduciary.node_count, Some(34));
1540        assert!(fiduciary.charges_apply_by_default);
1541        let system = catalog.subnet_by_principal(SUBNET_B).expect("system");
1542        assert_eq!(system.subnet_kind, SubnetKind::System);
1543        assert!(!system.charges_apply_by_default);
1544    }
1545
1546    #[test]
1547    fn registry_records_preserve_cloud_engine_subnet_type() {
1548        let request = MainnetRegistryFetchRequest {
1549            endpoint: "https://icp-api.io".to_string(),
1550            fetched_at: "2026-06-04T00:00:00Z".to_string(),
1551            fetched_by: "test".to_string(),
1552        };
1553        let subnet_records = BTreeMap::from([(
1554            SUBNET_A.to_string(),
1555            subnet_record(SubnetType::CloudEngine, 13),
1556        )]);
1557        let catalog = catalog_from_parts_for_test(
1558            &request,
1559            42,
1560            subnet_list_record([SUBNET_A]),
1561            routing_table_record([(CANISTER_A, CANISTER_A, SUBNET_A)]),
1562            subnet_records,
1563        )
1564        .expect("catalog");
1565
1566        let subnet = catalog.subnet_by_principal(SUBNET_A).expect("subnet");
1567        assert_eq!(subnet.subnet_kind, SubnetKind::CloudEngine);
1568        assert!(subnet.charges_apply_by_default);
1569    }
1570
1571    #[test]
1572    fn get_value_response_reports_large_value_chunk_keys() {
1573        let response = RegistryGetValueResponse {
1574            error: None,
1575            version: 1,
1576            content: Some(registry_get_value_response::Content::LargeValueChunkKeys(
1577                proto::LargeValueChunkKeys {
1578                    chunk_content_sha256s: vec![vec![1], vec![2]],
1579                },
1580            )),
1581            timestamp_nanoseconds: 0,
1582        };
1583
1584        let content = registry_value_content_from_response("routing_table", response)
1585            .expect("large value chunk keys");
1586
1587        match content {
1588            RegistryValueContent::LargeValueChunkKeys(keys) => {
1589                assert_eq!(keys.chunk_content_sha256s, vec![vec![1], vec![2]]);
1590            }
1591            RegistryValueContent::Value(value) => {
1592                panic!("expected chunk keys, got inline value {value:?}");
1593            }
1594        }
1595    }
1596
1597    #[test]
1598    fn registry_get_chunk_request_candid_round_trips() {
1599        let request = RegistryGetChunkRequest {
1600            content_sha256: Some(vec![1, 2, 3]),
1601        };
1602
1603        let bytes = Encode!(&request).expect("encode");
1604        let decoded = Decode!(&bytes, RegistryGetChunkRequest).expect("decode");
1605
1606        assert_eq!(decoded, request);
1607    }
1608
1609    #[test]
1610    fn governance_node_provider_response_converts_to_domain_structs() {
1611        let request = MainnetRegistryFetchRequest {
1612            endpoint: "https://icp-api.io".to_string(),
1613            fetched_at: "2026-06-04T00:00:00Z".to_string(),
1614            fetched_by: "test".to_string(),
1615        };
1616        let node_counts = BTreeMap::from([("aaaaa-aa".to_string(), 2)]);
1617        let response = ListNodeProvidersResponse {
1618            node_providers: vec![
1619                governance_node_provider("ryjl3-tyaaa-aaaaa-aaaba-cai", None),
1620                governance_node_provider("aaaaa-aa", Some(vec![0xab, 0xcd])),
1621            ],
1622        };
1623
1624        let list = node_provider_list_from_response(&request, response, node_counts, 42)
1625            .expect("node providers");
1626
1627        assert_eq!(list.network, MAINNET_NETWORK);
1628        assert_eq!(list.governance_canister_id, MAINNET_GOVERNANCE_CANISTER_ID);
1629        assert_eq!(list.registry_canister_id, MAINNET_REGISTRY_CANISTER_ID);
1630        assert_eq!(list.registry_version, 42);
1631        assert_eq!(list.node_providers.len(), 2);
1632        assert_eq!(list.node_providers[0].principal, "aaaaa-aa");
1633        assert_eq!(list.node_providers[0].node_count, Some(2));
1634        assert_eq!(
1635            list.node_providers[0].reward_account_hex.as_deref(),
1636            Some("abcd")
1637        );
1638        assert_eq!(
1639            list.node_providers[1].principal,
1640            "ryjl3-tyaaa-aaaaa-aaaba-cai"
1641        );
1642        assert_eq!(list.node_providers[1].node_count, Some(0));
1643        assert_eq!(list.node_providers[1].reward_account_hex, None);
1644    }
1645
1646    #[test]
1647    fn node_provider_counts_follow_subnet_nodes_to_providers() {
1648        let provider_a = Principal::self_authenticating(b"provider-a").to_text();
1649        let provider_b = Principal::self_authenticating(b"provider-b").to_text();
1650        let operator_a = Principal::self_authenticating(b"operator-a").to_text();
1651        let operator_b = Principal::self_authenticating(b"operator-b").to_text();
1652        let node_a = Principal::self_authenticating(b"node-a").to_text();
1653        let node_b = Principal::self_authenticating(b"node-b").to_text();
1654        let node_c = Principal::self_authenticating(b"node-c").to_text();
1655        let subnet = Principal::self_authenticating(b"subnet").to_text();
1656        let subnet_records = BTreeMap::from([(
1657            subnet,
1658            SubnetRecord {
1659                membership: vec![
1660                    principal_raw(&node_a),
1661                    principal_raw(&node_b),
1662                    principal_raw(&node_c),
1663                ],
1664                subnet_type: SubnetType::Application as i32,
1665                canister_cycles_cost_schedule: 0,
1666            },
1667        )]);
1668        let node_principals =
1669            assigned_node_principals_from_subnets(&subnet_records).expect("node principals");
1670        let node_records = BTreeMap::from([
1671            (node_a, node_record(&operator_a)),
1672            (node_b, node_record(&operator_a)),
1673            (node_c, node_record(&operator_b)),
1674        ]);
1675        let node_operator_records = BTreeMap::from([
1676            (operator_a, node_operator_record(&provider_a)),
1677            (operator_b, node_operator_record(&provider_b)),
1678        ]);
1679
1680        let counts = node_provider_counts_from_records(
1681            &node_principals,
1682            &node_records,
1683            &node_operator_records,
1684        )
1685        .expect("provider counts");
1686
1687        assert_eq!(counts.get(&provider_a), Some(&2));
1688        assert_eq!(counts.get(&provider_b), Some(&1));
1689    }
1690
1691    #[test]
1692    fn node_operator_list_follows_assigned_nodes_to_operator_records() {
1693        let request = MainnetRegistryFetchRequest {
1694            endpoint: "https://icp-api.io".to_string(),
1695            fetched_at: "2026-06-04T00:00:00Z".to_string(),
1696            fetched_by: "test".to_string(),
1697        };
1698        let provider = Principal::self_authenticating(b"provider").to_text();
1699        let primary_operator = Principal::self_authenticating(b"operator-a").to_text();
1700        let secondary_operator = Principal::self_authenticating(b"operator-b").to_text();
1701        let node_a = Principal::self_authenticating(b"node-a").to_text();
1702        let node_b = Principal::self_authenticating(b"node-b").to_text();
1703        let node_c = Principal::self_authenticating(b"node-c").to_text();
1704        let subnet = Principal::self_authenticating(b"subnet").to_text();
1705        let inventory = RegistryRelationInventory {
1706            node_principals: BTreeSet::from([node_a.clone(), node_b.clone(), node_c.clone()]),
1707            node_records: BTreeMap::from([
1708                (node_a.clone(), node_record(&primary_operator)),
1709                (node_b.clone(), node_record(&primary_operator)),
1710                (node_c.clone(), node_record(&secondary_operator)),
1711            ]),
1712            node_operator_records: BTreeMap::from([
1713                (
1714                    primary_operator.clone(),
1715                    NodeOperatorRecord {
1716                        node_operator_principal_id: principal_raw(&primary_operator),
1717                        node_allowance: 4,
1718                        node_provider_principal_id: principal_raw(&provider),
1719                        dc_id: "dc-a".to_string(),
1720                    },
1721                ),
1722                (
1723                    secondary_operator.clone(),
1724                    NodeOperatorRecord {
1725                        node_operator_principal_id: principal_raw(&secondary_operator),
1726                        node_allowance: 7,
1727                        node_provider_principal_id: principal_raw(&provider),
1728                        dc_id: "dc-b".to_string(),
1729                    },
1730                ),
1731            ]),
1732            subnet_records: BTreeMap::from([(
1733                subnet,
1734                SubnetRecord {
1735                    membership: vec![
1736                        principal_raw(&node_a),
1737                        principal_raw(&node_b),
1738                        principal_raw(&node_c),
1739                    ],
1740                    subnet_type: SubnetType::Application as i32,
1741                    canister_cycles_cost_schedule: 0,
1742                },
1743            )]),
1744            data_center_records: BTreeMap::new(),
1745        };
1746
1747        let list =
1748            node_operator_list_from_inventory(&request, inventory, 42).expect("node operators");
1749
1750        assert_eq!(list.network, MAINNET_NETWORK);
1751        assert_eq!(list.registry_canister_id, MAINNET_REGISTRY_CANISTER_ID);
1752        assert_eq!(list.registry_version, 42);
1753        assert_eq!(list.node_operators.len(), 2);
1754        let primary_result = list
1755            .node_operators
1756            .iter()
1757            .find(|operator| operator.principal == primary_operator)
1758            .expect("primary operator");
1759        assert_eq!(primary_result.node_provider_principal, provider);
1760        assert_eq!(primary_result.node_allowance, 4);
1761        assert_eq!(primary_result.data_center_id, "dc-a");
1762        assert_eq!(primary_result.node_count, Some(2));
1763        let secondary_result = list
1764            .node_operators
1765            .iter()
1766            .find(|operator| operator.principal == secondary_operator)
1767            .expect("secondary operator");
1768        assert_eq!(secondary_result.node_count, Some(1));
1769    }
1770
1771    #[test]
1772    fn node_list_follows_nodes_to_subnets_operators_and_providers() {
1773        let request = MainnetRegistryFetchRequest {
1774            endpoint: "https://icp-api.io".to_string(),
1775            fetched_at: "2026-06-04T00:00:00Z".to_string(),
1776            fetched_by: "test".to_string(),
1777        };
1778        let provider = Principal::self_authenticating(b"provider").to_text();
1779        let operator = Principal::self_authenticating(b"operator").to_text();
1780        let node = Principal::self_authenticating(b"node").to_text();
1781        let subnet = Principal::self_authenticating(b"subnet").to_text();
1782        let inventory = RegistryRelationInventory {
1783            node_principals: BTreeSet::from([node.clone()]),
1784            node_records: BTreeMap::from([(node.clone(), node_record(&operator))]),
1785            node_operator_records: BTreeMap::from([(
1786                operator.clone(),
1787                NodeOperatorRecord {
1788                    node_operator_principal_id: principal_raw(&operator),
1789                    node_allowance: 4,
1790                    node_provider_principal_id: principal_raw(&provider),
1791                    dc_id: "dc-a".to_string(),
1792                },
1793            )]),
1794            subnet_records: BTreeMap::from([(
1795                subnet.clone(),
1796                SubnetRecord {
1797                    membership: vec![principal_raw(&node)],
1798                    subnet_type: SubnetType::Application as i32,
1799                    canister_cycles_cost_schedule: 0,
1800                },
1801            )]),
1802            data_center_records: BTreeMap::new(),
1803        };
1804
1805        let list = node_list_from_inventory(&request, inventory, 42).expect("nodes");
1806
1807        assert_eq!(list.registry_version, 42);
1808        assert_eq!(list.nodes.len(), 1);
1809        assert_eq!(list.nodes[0].principal, node);
1810        assert_eq!(list.nodes[0].node_operator_principal, operator);
1811        assert_eq!(list.nodes[0].node_provider_principal, provider);
1812        assert_eq!(list.nodes[0].subnet_principal, subnet);
1813        assert_eq!(list.nodes[0].subnet_kind, "application");
1814        assert_eq!(list.nodes[0].data_center_id, "dc-a");
1815    }
1816
1817    #[test]
1818    fn data_center_list_aggregates_registry_relations() {
1819        let request = MainnetRegistryFetchRequest {
1820            endpoint: "https://icp-api.io".to_string(),
1821            fetched_at: "2026-06-04T00:00:00Z".to_string(),
1822            fetched_by: "test".to_string(),
1823        };
1824        let provider_a = Principal::self_authenticating(b"provider-a").to_text();
1825        let provider_b = Principal::self_authenticating(b"provider-b").to_text();
1826        let operator_a = Principal::self_authenticating(b"operator-a").to_text();
1827        let operator_b = Principal::self_authenticating(b"operator-b").to_text();
1828        let node_a = Principal::self_authenticating(b"node-a").to_text();
1829        let node_b = Principal::self_authenticating(b"node-b").to_text();
1830        let node_c = Principal::self_authenticating(b"node-c").to_text();
1831        let subnet = Principal::self_authenticating(b"subnet").to_text();
1832        let inventory = RegistryRelationInventory {
1833            node_principals: BTreeSet::from([node_a.clone(), node_b.clone(), node_c.clone()]),
1834            node_records: BTreeMap::from([
1835                (node_a.clone(), node_record(&operator_a)),
1836                (node_b.clone(), node_record(&operator_a)),
1837                (node_c.clone(), node_record(&operator_b)),
1838            ]),
1839            node_operator_records: BTreeMap::from([
1840                (
1841                    operator_a.clone(),
1842                    NodeOperatorRecord {
1843                        node_operator_principal_id: principal_raw(&operator_a),
1844                        node_allowance: 4,
1845                        node_provider_principal_id: principal_raw(&provider_a),
1846                        dc_id: "dc-a".to_string(),
1847                    },
1848                ),
1849                (
1850                    operator_b.clone(),
1851                    NodeOperatorRecord {
1852                        node_operator_principal_id: principal_raw(&operator_b),
1853                        node_allowance: 7,
1854                        node_provider_principal_id: principal_raw(&provider_b),
1855                        dc_id: "DC-A".to_string(),
1856                    },
1857                ),
1858            ]),
1859            subnet_records: BTreeMap::from([(
1860                subnet,
1861                SubnetRecord {
1862                    membership: vec![
1863                        principal_raw(&node_a),
1864                        principal_raw(&node_b),
1865                        principal_raw(&node_c),
1866                    ],
1867                    subnet_type: SubnetType::Application as i32,
1868                    canister_cycles_cost_schedule: 0,
1869                },
1870            )]),
1871            data_center_records: BTreeMap::from([(
1872                "dc-a".to_string(),
1873                DataCenterRecord {
1874                    id: "dc-a".to_string(),
1875                    region: "eu-west".to_string(),
1876                    owner: "example owner".to_string(),
1877                    gps: Some(proto::Gps {
1878                        latitude: 48.8566,
1879                        longitude: 2.3522,
1880                    }),
1881                },
1882            )]),
1883        };
1884
1885        let list = data_center_list_from_inventory(&request, inventory, 42).expect("data centers");
1886
1887        assert_eq!(list.registry_version, 42);
1888        assert_eq!(list.data_centers.len(), 1);
1889        assert_eq!(list.data_centers[0].id, "dc-a");
1890        assert_eq!(list.data_centers[0].region, "eu-west");
1891        assert_eq!(list.data_centers[0].owner, "example owner");
1892        assert_eq!(list.data_centers[0].latitude, Some(48.8566));
1893        assert_eq!(list.data_centers[0].longitude, Some(2.3522));
1894        assert_eq!(list.data_centers[0].node_operator_count, 2);
1895        assert_eq!(list.data_centers[0].node_provider_count, 2);
1896        assert_eq!(list.data_centers[0].node_count, 3);
1897    }
1898
1899    #[test]
1900    fn governance_node_provider_requires_principal() {
1901        let err = node_provider_from_governance(
1902            GovernanceNodeProvider {
1903                id: None,
1904                reward_account: None,
1905            },
1906            &BTreeMap::new(),
1907        )
1908        .expect_err("missing principal");
1909
1910        assert!(matches!(
1911            err,
1912            RegistryFetchError::MissingField { field } if field == "node_provider.id"
1913        ));
1914    }
1915
1916    #[test]
1917    fn validated_chunk_append_concatenates_matching_chunks() {
1918        let first = b"hello ".to_vec();
1919        let second = b"world".to_vec();
1920        let first_hash = sha256_digest(&first);
1921        let second_hash = sha256_digest(&second);
1922        let mut value = Vec::new();
1923
1924        append_validated_chunk(&mut value, &first_hash, first).expect("first chunk");
1925        append_validated_chunk(&mut value, &second_hash, second).expect("second chunk");
1926
1927        assert_eq!(value, b"hello world");
1928    }
1929
1930    #[test]
1931    fn validated_chunk_append_rejects_hash_mismatch() {
1932        let expected = sha256_digest(b"expected");
1933
1934        let err = append_validated_chunk(&mut Vec::new(), &expected, b"actual".to_vec())
1935            .expect_err("hash mismatch");
1936
1937        assert!(matches!(
1938            err,
1939            RegistryFetchError::ChunkHashMismatch {
1940                sha256,
1941                actual_sha256
1942            } if sha256 == hex_bytes(&expected)
1943                && actual_sha256 == hex_bytes(&sha256_digest(b"actual"))
1944        ));
1945    }
1946
1947    #[test]
1948    fn get_value_response_reports_registry_errors() {
1949        let response = RegistryGetValueResponse {
1950            error: Some(proto::RegistryError {
1951                code: RegistryErrorCode::KeyNotPresent as i32,
1952                reason: "missing".to_string(),
1953                key: b"routing_table".to_vec(),
1954            }),
1955            version: 1,
1956            content: None,
1957            timestamp_nanoseconds: 0,
1958        };
1959
1960        let err =
1961            registry_value_content_from_response("routing_table", response).expect_err("registry");
1962
1963        assert!(matches!(
1964            err,
1965            RegistryFetchError::RegistryValue {
1966                key,
1967                code,
1968                reason
1969            } if key == "routing_table" && code == "key_not_present" && reason == "missing"
1970        ));
1971    }
1972
1973    fn catalog_from_parts_for_test(
1974        request: &MainnetRegistryFetchRequest,
1975        registry_version: u64,
1976        subnet_list: SubnetListRecord,
1977        routing_table: RoutingTable,
1978        subnet_records: BTreeMap<String, SubnetRecord>,
1979    ) -> Result<SubnetCatalog, RegistryFetchError> {
1980        if subnet_list.subnets.is_empty() {
1981            return Err(RegistryFetchError::EmptySubnetList);
1982        }
1983        if routing_table.entries.is_empty() {
1984            return Err(RegistryFetchError::EmptyRoutingTable);
1985        }
1986        let mut subnets = subnet_list
1987            .subnets
1988            .iter()
1989            .map(|subnet_raw| {
1990                let subnet_principal = principal_text_from_raw(subnet_raw, "subnet_list.subnets")?;
1991                let record = subnet_records.get(&subnet_principal).ok_or(
1992                    RegistryFetchError::MissingField {
1993                        field: "subnet_record",
1994                    },
1995                )?;
1996                Ok(subnet_info_from_record(&subnet_principal, record))
1997            })
1998            .collect::<Result<Vec<_>, RegistryFetchError>>()?;
1999        subnets.sort_by(|left, right| left.subnet_principal.cmp(&right.subnet_principal));
2000        let mut catalog = SubnetCatalog {
2001            catalog_schema_version: CATALOG_SCHEMA_VERSION,
2002            network: MAINNET_NETWORK.to_string(),
2003            registry_canister_id: MAINNET_REGISTRY_CANISTER_ID.to_string(),
2004            registry_version,
2005            fetched_at: request.fetched_at.clone(),
2006            fetched_by: request.fetched_by.clone(),
2007            source_endpoint: request.endpoint.clone(),
2008            resolver_backend: "local-nns-subnet-catalog".to_string(),
2009            subnets,
2010            routing_ranges: routing_ranges_from_table(&routing_table)?,
2011        };
2012        apply_mainnet_annotations(&mut catalog);
2013        catalog.validate()?;
2014        Ok(catalog)
2015    }
2016
2017    fn subnet_list_record<const N: usize>(subnets: [&str; N]) -> SubnetListRecord {
2018        SubnetListRecord {
2019            subnets: subnets.iter().map(|subnet| principal_raw(subnet)).collect(),
2020        }
2021    }
2022
2023    fn subnet_record(subnet_type: SubnetType, members: usize) -> SubnetRecord {
2024        SubnetRecord {
2025            membership: (0..members)
2026                .map(|index| {
2027                    let index = u8::try_from(index).expect("fixture member index fits in u8");
2028                    principal_raw(&Principal::self_authenticating([index]).to_text())
2029                })
2030                .collect(),
2031            subnet_type: subnet_type as i32,
2032            canister_cycles_cost_schedule: 0,
2033        }
2034    }
2035
2036    fn routing_table_record<const N: usize>(ranges: [(&str, &str, &str); N]) -> RoutingTable {
2037        RoutingTable {
2038            entries: ranges
2039                .iter()
2040                .map(|(start, end, subnet)| RoutingTableEntry {
2041                    range: Some(CanisterIdRange {
2042                        start_canister_id: Some(canister_id(start)),
2043                        end_canister_id: Some(canister_id(end)),
2044                    }),
2045                    subnet_id: Some(subnet_id(subnet)),
2046                })
2047                .collect(),
2048        }
2049    }
2050
2051    fn canister_id(principal: &str) -> CanisterId {
2052        CanisterId {
2053            principal_id: Some(PrincipalId {
2054                raw: principal_raw(principal),
2055            }),
2056        }
2057    }
2058
2059    fn subnet_id(principal: &str) -> SubnetId {
2060        SubnetId {
2061            principal_id: Some(PrincipalId {
2062                raw: principal_raw(principal),
2063            }),
2064        }
2065    }
2066
2067    fn principal_raw(principal: &str) -> Vec<u8> {
2068        Principal::from_text(principal)
2069            .expect("principal")
2070            .as_slice()
2071            .to_vec()
2072    }
2073
2074    fn governance_node_provider(
2075        principal: &str,
2076        reward_account_hash: Option<Vec<u8>>,
2077    ) -> GovernanceNodeProvider {
2078        GovernanceNodeProvider {
2079            id: Some(Principal::from_text(principal).expect("principal")),
2080            reward_account: reward_account_hash.map(|hash| GovernanceAccountIdentifier { hash }),
2081        }
2082    }
2083
2084    fn node_record(node_operator: &str) -> NodeRecord {
2085        NodeRecord {
2086            node_operator_id: principal_raw(node_operator),
2087        }
2088    }
2089
2090    fn node_operator_record(node_provider: &str) -> NodeOperatorRecord {
2091        NodeOperatorRecord {
2092            node_operator_principal_id: Vec::new(),
2093            node_allowance: 0,
2094            node_provider_principal_id: principal_raw(node_provider),
2095            dc_id: "dc1".to_string(),
2096        }
2097    }
2098}