Skip to main content

sui_proxy/
peers.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3use anyhow::{Context, Result, bail};
4use fastcrypto::ed25519::Ed25519PublicKey;
5use fastcrypto::encoding::Base64;
6use fastcrypto::encoding::Encoding;
7use fastcrypto::traits::ToFromBytes;
8use futures::stream::{self, StreamExt};
9use once_cell::sync::Lazy;
10use prometheus::{CounterVec, HistogramVec, IntGaugeVec};
11use prometheus::{register_counter_vec, register_histogram_vec, register_int_gauge_vec};
12use prost_types::Value as JsonValue;
13use prost_types::value::Kind as JsonKind;
14use std::collections::BTreeMap;
15use std::str::FromStr;
16use std::{
17    collections::HashMap,
18    sync::{Arc, RwLock},
19    time::Duration,
20};
21use sui_rpc::Client as SuiRpcClient;
22use sui_rpc::field::{FieldMask, FieldMaskUtil};
23use sui_rpc::proto::sui::rpc::v2::{Epoch, GetEpochRequest, GetObjectRequest, Object};
24use sui_sdk_types::{Address, TypeTag};
25use sui_tls::Allower;
26use sui_types::SUI_BRIDGE_OBJECT_ID;
27use sui_types::base_types::SuiAddress;
28use sui_types::bridge::{BridgeInnerV1, BridgeSummary, BridgeTrait, BridgeWrapper};
29use sui_types::dynamic_field::Field;
30use sui_types::sui_system_state::sui_system_state_summary::SuiSystemStateSummary;
31use tracing::{debug, error, info, warn};
32use url::Url;
33
34static JSON_RPC_STATE: Lazy<CounterVec> = Lazy::new(|| {
35    register_counter_vec!(
36        "json_rpc_state",
37        "Number of successful/failed requests made.",
38        &["rpc_method", "status"]
39    )
40    .unwrap()
41});
42static JSON_RPC_DURATION: Lazy<HistogramVec> = Lazy::new(|| {
43    register_histogram_vec!(
44        "json_rpc_duration_seconds",
45        "The Sui RPC latencies in seconds.",
46        &["rpc_method"],
47        vec![
48            0.0008, 0.0016, 0.0032, 0.0064, 0.0128, 0.0256, 0.0512, 0.1024, 0.2048, 0.4096, 0.8192,
49            1.0, 1.25, 1.5, 1.75, 2.0, 4.0, 8.0
50        ],
51    )
52    .unwrap()
53});
54
55/// The on-chain hashi committee epoch as last observed by the resolver. A flatlining
56/// value relative to the actual chain epoch indicates the resolver is stuck.
57static HASHI_OBSERVED_EPOCH: Lazy<IntGaugeVec> = Lazy::new(|| {
58    register_int_gauge_vec!(
59        "hashi_proxy_observed_committee_epoch",
60        "Most recent hashi CommitteeSet.epoch observed by the resolver.",
61        &["hashi_object_id"]
62    )
63    .unwrap()
64});
65
66/// Number of hashi members currently in the allowlist (current + pending committee with
67/// a valid 32-byte tls_public_key).
68static HASHI_ALLOWED_MEMBERS: Lazy<IntGaugeVec> = Lazy::new(|| {
69    register_int_gauge_vec!(
70        "hashi_proxy_allowed_members",
71        "Number of hashi members on the proxy allowlist.",
72        &["hashi_object_id"]
73    )
74    .unwrap()
75});
76
77/// AllowedPeers is a mapping of public key to AllowedPeer data
78pub type AllowedPeers = Arc<RwLock<HashMap<Ed25519PublicKey, AllowedPeer>>>;
79
80type MetricsPubKeys = Arc<RwLock<HashMap<String, Ed25519PublicKey>>>;
81
82#[derive(Hash, PartialEq, Eq, Debug, Clone)]
83pub struct AllowedPeer {
84    pub name: String,
85    pub public_key: Ed25519PublicKey,
86}
87
88/// Cache of `SuiAddress -> validator name` from the latest sui system state poll.
89/// Used by bridge/hashi resolvers to label peers by friendly validator name without
90/// each resolver re-fetching the validator set.
91type ValidatorNames = Arc<RwLock<BTreeMap<SuiAddress, String>>>;
92
93/// SuiNodeProvider queries the sui blockchain and keeps a record of known validators based on the response from
94/// sui_getValidators.  The node name, public key and other info is extracted from the chain and stored in this
95/// data structure.  We pass this struct to the tls verifier and it depends on the state contained within.
96/// Handlers also use this data in an Extractor extension to check incoming clients on the http api against known keys.
97#[derive(Debug, Clone)]
98pub struct SuiNodeProvider {
99    sui_nodes: AllowedPeers,
100    bridge_nodes: AllowedPeers,
101    hashi_nodes: AllowedPeers,
102    static_nodes: AllowedPeers,
103    sui_validator_names: ValidatorNames,
104    rpc_url: String,
105    rpc_poll_interval: Duration,
106    /// Object ID of the `hashi::hashi::Hashi` shared object on the chain identified
107    /// by `rpc_url`. `None` disables the hashi resolver entirely.
108    hashi_object_id: Option<String>,
109}
110
111impl Allower for SuiNodeProvider {
112    fn allowed(&self, key: &Ed25519PublicKey) -> bool {
113        self.static_nodes.read().unwrap().contains_key(key)
114            || self.sui_nodes.read().unwrap().contains_key(key)
115            || self.bridge_nodes.read().unwrap().contains_key(key)
116            || self.hashi_nodes.read().unwrap().contains_key(key)
117    }
118}
119
120impl SuiNodeProvider {
121    pub fn new(
122        rpc_url: String,
123        rpc_poll_interval: Duration,
124        static_peers: Vec<AllowedPeer>,
125        hashi_object_id: Option<String>,
126    ) -> Self {
127        // build our hashmap with the static pub keys. we only do this one time at binary startup.
128        let static_nodes: HashMap<Ed25519PublicKey, AllowedPeer> = static_peers
129            .into_iter()
130            .map(|v| (v.public_key.clone(), v))
131            .collect();
132        let static_nodes = Arc::new(RwLock::new(static_nodes));
133        let sui_nodes = Arc::new(RwLock::new(HashMap::new()));
134        let bridge_nodes = Arc::new(RwLock::new(HashMap::new()));
135        let hashi_nodes = Arc::new(RwLock::new(HashMap::new()));
136        let sui_validator_names = Arc::new(RwLock::new(BTreeMap::new()));
137        Self {
138            sui_nodes,
139            bridge_nodes,
140            hashi_nodes,
141            static_nodes,
142            sui_validator_names,
143            rpc_url,
144            rpc_poll_interval,
145            hashi_object_id,
146        }
147    }
148
149    /// get is used to retrieve peer info in our handlers
150    pub fn get(&self, key: &Ed25519PublicKey) -> Option<AllowedPeer> {
151        debug!("look for {:?}", key);
152        // check static nodes first
153        if let Some(v) = self.static_nodes.read().unwrap().get(key) {
154            return Some(AllowedPeer {
155                name: v.name.to_owned(),
156                public_key: v.public_key.to_owned(),
157            });
158        }
159        // check sui validators
160        if let Some(v) = self.sui_nodes.read().unwrap().get(key) {
161            return Some(AllowedPeer {
162                name: v.name.to_owned(),
163                public_key: v.public_key.to_owned(),
164            });
165        }
166        // check bridge validators
167        if let Some(v) = self.bridge_nodes.read().unwrap().get(key) {
168            return Some(AllowedPeer {
169                name: v.name.to_owned(),
170                public_key: v.public_key.to_owned(),
171            });
172        }
173        // check hashi committee members
174        if let Some(v) = self.hashi_nodes.read().unwrap().get(key) {
175            return Some(AllowedPeer {
176                name: v.name.to_owned(),
177                public_key: v.public_key.to_owned(),
178            });
179        }
180        None
181    }
182
183    /// Get a mutable reference to the allowed sui validator map
184    pub fn get_sui_mut(&mut self) -> &mut AllowedPeers {
185        &mut self.sui_nodes
186    }
187
188    /// get_validators will retrieve known validators
189    async fn get_validators(url: String) -> Result<SuiSystemStateSummary> {
190        let rpc_method = "sui_rpc.LedgerService.GetEpoch:SystemState";
191        let _timer = JSON_RPC_DURATION
192            .with_label_values(&[rpc_method])
193            .start_timer();
194        let mut client = SuiRpcClient::new(url.to_owned())
195            .with_context(|| format!("creating sui-rpc client for {url}"))?;
196        let request = GetEpochRequest::default().with_read_mask(FieldMask::from_paths([
197            Epoch::path_builder().epoch(),
198            Epoch::path_builder().system_state().finish(),
199        ]));
200        let response = client
201            .ledger_client()
202            .get_epoch(request)
203            .await
204            .with_context(|| {
205                JSON_RPC_STATE
206                    .with_label_values(&[rpc_method, "failed_get"])
207                    .inc();
208                "unable to fetch system state over gRPC"
209            })?;
210
211        let response = response.into_inner();
212        let system_state = response
213            .epoch()
214            .system_state_opt()
215            .context("get_epoch response missing system_state")?;
216        let summary: SuiSystemStateSummary = system_state.try_into().with_context(|| {
217            JSON_RPC_STATE
218                .with_label_values(&[rpc_method, "failed_decode"])
219                .inc();
220            "unable to decode gRPC system state summary"
221        })?;
222        JSON_RPC_STATE
223            .with_label_values(&[rpc_method, "success"])
224            .inc();
225        Ok(summary)
226    }
227
228    /// get_bridge_validators will retrieve known bridge validators
229    async fn get_bridge_validators(url: String) -> Result<BridgeSummary> {
230        let rpc_method = "sui_rpc.LedgerService.GetObject:BridgeSummary";
231        let _timer = JSON_RPC_DURATION
232            .with_label_values(&[rpc_method])
233            .start_timer();
234        let mut client = SuiRpcClient::new(url.to_owned())
235            .with_context(|| format!("creating sui-rpc client for {url}"))?;
236        let bridge_wrapper_bcs = get_object_contents(
237            &mut client,
238            SUI_BRIDGE_OBJECT_ID.into(),
239            rpc_method,
240            "BridgeWrapper",
241        )
242        .await?;
243        let bridge_wrapper: BridgeWrapper =
244            bcs::from_bytes(&bridge_wrapper_bcs).with_context(|| {
245                JSON_RPC_STATE
246                    .with_label_values(&[rpc_method, "failed_decode_wrapper"])
247                    .inc();
248                "unable to decode BridgeWrapper from gRPC object contents"
249            })?;
250
251        let bridge_version = bridge_wrapper.version.version;
252        if bridge_version != 1 {
253            bail!("unsupported SuiBridge version: {bridge_version}");
254        }
255
256        let bridge_version_id: Address = bridge_wrapper.version.id.id.bytes.into();
257        let bridge_inner_id = bridge_version_id.derive_dynamic_child_id(
258            &TypeTag::U64,
259            &bcs::to_bytes(&bridge_version).expect("u64 always BCS-encodes"),
260        );
261        let field_bcs = get_object_contents(
262            &mut client,
263            bridge_inner_id,
264            rpc_method,
265            "BridgeInner dynamic field",
266        )
267        .await?;
268        let field: Field<u64, BridgeInnerV1> = bcs::from_bytes(&field_bcs).with_context(|| {
269            JSON_RPC_STATE
270                .with_label_values(&[rpc_method, "failed_decode_inner"])
271                .inc();
272            "unable to decode BridgeInner dynamic field from gRPC object contents"
273        })?;
274        let summary = field.value.try_into_bridge_summary().with_context(|| {
275            JSON_RPC_STATE
276                .with_label_values(&[rpc_method, "failed_summary"])
277                .inc();
278            "unable to build bridge summary"
279        })?;
280        JSON_RPC_STATE
281            .with_label_values(&[rpc_method, "success"])
282            .inc();
283        Ok(summary)
284    }
285
286    async fn update_sui_validator_set(&self) {
287        match Self::get_validators(self.rpc_url.to_owned()).await {
288            Ok(summary) => {
289                // Snapshot the validator-address -> name map for downstream resolvers
290                // (bridge/hashi) before we hand `summary.active_validators` off to the
291                // network-key extractor.
292                let names: BTreeMap<SuiAddress, String> = summary
293                    .active_validators
294                    .iter()
295                    .map(|v| (v.sui_address, v.name.clone()))
296                    .collect();
297                {
298                    let mut nw = self.sui_validator_names.write().unwrap();
299                    *nw = names;
300                }
301
302                let validators = extract(summary);
303                let mut allow = self.sui_nodes.write().unwrap();
304                allow.clear();
305                allow.extend(validators);
306                info!(
307                    "{} sui validators managed to make it on the allow list",
308                    allow.len()
309                );
310            }
311            Err(error) => {
312                JSON_RPC_STATE
313                    .with_label_values(&["update_peer_count", "failed"])
314                    .inc();
315                error!("unable to refresh peer list: {error}");
316            }
317        };
318    }
319
320    async fn update_hashi_committee_set(&self, hashi_object_id: &str) {
321        let validator_names: BTreeMap<SuiAddress, String> =
322            self.sui_validator_names.read().unwrap().clone();
323
324        match resolve_hashi_committee(&self.rpc_url, hashi_object_id, &validator_names).await {
325            Ok(result) => {
326                HASHI_OBSERVED_EPOCH
327                    .with_label_values(&[hashi_object_id])
328                    .set(result.epoch as i64);
329                HASHI_ALLOWED_MEMBERS
330                    .with_label_values(&[hashi_object_id])
331                    .set(result.peers.len() as i64);
332                let mut allow = self.hashi_nodes.write().unwrap();
333                allow.clear();
334                allow.extend(result.peers);
335                info!(
336                    epoch = result.epoch,
337                    pending_epoch = ?result.pending_epoch,
338                    "{} hashi members on the allow list",
339                    allow.len(),
340                );
341            }
342            Err(error) => {
343                JSON_RPC_STATE
344                    .with_label_values(&["update_hashi_committee_set", "failed"])
345                    .inc();
346                error!("unable to refresh hashi peer list: {error:#}");
347            }
348        }
349    }
350
351    async fn update_bridge_validator_set(&self, metrics_keys: MetricsPubKeys) {
352        let sui_system = match Self::get_validators(self.rpc_url.to_owned()).await {
353            Ok(summary) => summary,
354            Err(error) => {
355                JSON_RPC_STATE
356                    .with_label_values(&["update_bridge_peer_count", "failed"])
357                    .inc();
358                error!("unable to get sui system state: {error}");
359                return;
360            }
361        };
362        match Self::get_bridge_validators(self.rpc_url.to_owned()).await {
363            Ok(summary) => {
364                let names = sui_system
365                    .active_validators
366                    .into_iter()
367                    .map(|v| (v.sui_address, v.name))
368                    .collect();
369                let validators = extract_bridge(summary, Arc::new(names), metrics_keys).await;
370                let mut allow = self.bridge_nodes.write().unwrap();
371                allow.clear();
372                allow.extend(validators);
373                info!(
374                    "{} bridge validators managed to make it on the allow list",
375                    allow.len()
376                );
377            }
378            Err(error) => {
379                JSON_RPC_STATE
380                    .with_label_values(&["update_bridge_peer_count", "failed"])
381                    .inc();
382                error!("unable to refresh sui bridge peer list: {error}");
383            }
384        };
385    }
386
387    /// poll_peer_list will act as a refresh interval for our cache
388    pub fn poll_peer_list(&self) {
389        info!("Started polling for peers using Sui gRPC: {}", self.rpc_url);
390
391        let rpc_poll_interval = self.rpc_poll_interval;
392        let cloned_self = self.clone();
393        let bridge_metrics_keys: MetricsPubKeys = Arc::new(RwLock::new(HashMap::new()));
394        tokio::spawn(async move {
395            let mut interval = tokio::time::interval(rpc_poll_interval);
396            interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
397
398            loop {
399                interval.tick().await;
400
401                // The sui validator set must update first; bridge and hashi resolvers
402                // read the cached `sui_validator_names` for friendly labeling.
403                cloned_self.update_sui_validator_set().await;
404                cloned_self
405                    .update_bridge_validator_set(bridge_metrics_keys.clone())
406                    .await;
407                if let Some(hashi_object_id) = cloned_self.hashi_object_id.as_deref() {
408                    cloned_self
409                        .update_hashi_committee_set(hashi_object_id)
410                        .await;
411                }
412            }
413        });
414    }
415}
416
417async fn get_object_contents(
418    client: &mut SuiRpcClient,
419    object_id: Address,
420    rpc_method: &'static str,
421    object_name: &str,
422) -> Result<Vec<u8>> {
423    let response = client
424        .ledger_client()
425        .get_object(
426            GetObjectRequest::new(&object_id).with_read_mask(FieldMask::from_paths([
427                Object::path_builder().contents().finish(),
428            ])),
429        )
430        .await
431        .with_context(|| {
432            JSON_RPC_STATE
433                .with_label_values(&[rpc_method, "failed_get"])
434                .inc();
435            format!("get_object failed for {object_name} {object_id}")
436        })?;
437
438    let inner = response.into_inner();
439    let contents = inner.object().contents_opt().with_context(|| {
440        JSON_RPC_STATE
441            .with_label_values(&[rpc_method, "missing_contents"])
442            .inc();
443        format!("get_object response for {object_name} {object_id} missing contents")
444    })?;
445
446    Ok(contents.value().to_vec())
447}
448
449/// extract will get the network pubkey bytes from a SuiValidatorSummary type.  This type comes from a
450/// full node rpc result.  See get_validators for details.  The key here, if extracted successfully, will
451/// ultimately be stored in the allow list and let us communicate with those actual peers via tls.
452fn extract(
453    summary: SuiSystemStateSummary,
454) -> impl Iterator<Item = (Ed25519PublicKey, AllowedPeer)> {
455    summary.active_validators.into_iter().filter_map(|vm| {
456        match Ed25519PublicKey::from_bytes(&vm.network_pubkey_bytes) {
457            Ok(public_key) => {
458                debug!(
459                    "adding public key {:?} for sui validator {:?}",
460                    public_key, vm.name
461                );
462                Some((
463                    public_key.clone(),
464                    AllowedPeer {
465                        name: vm.name,
466                        public_key,
467                    },
468                )) // scoped to filter_map
469            }
470            Err(error) => {
471                error!(
472                    "unable to decode public key for name: {:?} sui_address: {:?} error: {error}",
473                    vm.name, vm.sui_address
474                );
475                None // scoped to filter_map
476            }
477        }
478    })
479}
480
481async fn extract_bridge(
482    summary: BridgeSummary,
483    names: Arc<BTreeMap<SuiAddress, String>>,
484    metrics_keys: MetricsPubKeys,
485) -> Vec<(Ed25519PublicKey, AllowedPeer)> {
486    {
487        // Clean up the cache: retain only the metrics keys of the up-to-date bridge validator set
488        let mut metrics_keys_write = metrics_keys.write().unwrap();
489        metrics_keys_write.retain(|url, _| {
490            summary.committee.members.iter().any(|(_, cm)| {
491                String::from_utf8(cm.http_rest_url.clone()).ok().as_ref() == Some(url)
492            })
493        });
494    }
495
496    let client = reqwest::Client::builder()
497        .timeout(Duration::from_secs(10))
498        .build()
499        .unwrap();
500    let committee_members = summary.committee.members.clone();
501    let results: Vec<_> = stream::iter(committee_members)
502        .filter_map(|(_, cm)| {
503            let client = client.clone();
504            let metrics_keys = metrics_keys.clone();
505            let names = names.clone();
506            async move {
507                debug!(
508                    address =% cm.sui_address,
509                    "Extracting metrics public key for bridge node",
510                );
511
512                // Convert the Vec<u8> to a String and handle errors properly
513                let url_str = match String::from_utf8(cm.http_rest_url) {
514                    Ok(url) => url,
515                    Err(_) => {
516                        warn!(
517                            address =% cm.sui_address,
518                            "Invalid UTF-8 sequence in http_rest_url for bridge node ",
519                        );
520                        return None;
521                    }
522                };
523                // Parse the URL
524                let bridge_url = match Url::parse(&url_str) {
525                    Ok(url) => url,
526                    Err(_) => {
527                        warn!(url_str, "Unable to parse http_rest_url");
528                        return None;
529                    }
530                };
531
532                // Append "metrics_pub_key" to the path
533                let bridge_url = match append_path_segment(bridge_url, "metrics_pub_key") {
534                    Some(url) => url,
535                    None => {
536                        warn!(url_str, "Unable to append path segment to URL");
537                        return None;
538                    }
539                };
540
541                // Use the host portion of the http_rest_url as the "name"
542                let bridge_host = match bridge_url.host_str() {
543                    Some(host) => host,
544                    None => {
545                        warn!(url_str, "Hostname is missing from http_rest_url");
546                        return None;
547                    }
548                };
549                let bridge_name = names.get(&cm.sui_address).cloned().unwrap_or_else(|| {
550                    warn!(
551                        address =% cm.sui_address,
552                        "Bridge node not found in sui committee, using base URL as the name",
553                    );
554                    String::from(bridge_host)
555                });
556                let bridge_name = format!("bridge-{}", bridge_name);
557
558                let bridge_request_url = bridge_url.as_str();
559
560                let metrics_pub_key = match client.get(bridge_request_url).send().await {
561                    Ok(response) => {
562                        let raw = response.bytes().await.ok()?;
563                        let metrics_pub_key: String = match serde_json::from_slice(&raw) {
564                            Ok(key) => key,
565                            Err(error) => {
566                                warn!(?error, url_str, "Failed to deserialize response");
567                                return fallback_to_cached_key(
568                                    &metrics_keys,
569                                    &url_str,
570                                    &bridge_name,
571                                );
572                            }
573                        };
574                        let metrics_bytes = match Base64::decode(&metrics_pub_key) {
575                            Ok(pubkey_bytes) => pubkey_bytes,
576                            Err(error) => {
577                                warn!(
578                                    ?error,
579                                    bridge_name, "unable to decode public key for bridge node",
580                                );
581                                return None;
582                            }
583                        };
584                        match Ed25519PublicKey::from_bytes(&metrics_bytes) {
585                            Ok(pubkey) => {
586                                // Successfully fetched the key, update the cache
587                                let mut metrics_keys_write = metrics_keys.write().unwrap();
588                                metrics_keys_write.insert(url_str.clone(), pubkey.clone());
589                                debug!(
590                                    url_str,
591                                    public_key = ?pubkey,
592                                    "Successfully added bridge peer to metrics_keys"
593                                );
594                                pubkey
595                            }
596                            Err(error) => {
597                                warn!(
598                                    ?error,
599                                    bridge_request_url,
600                                    "unable to decode public key for bridge node",
601                                );
602                                return None;
603                            }
604                        }
605                    }
606                    Err(_) => {
607                        return fallback_to_cached_key(&metrics_keys, &url_str, &bridge_name);
608                    }
609                };
610                Some((
611                    metrics_pub_key.clone(),
612                    AllowedPeer {
613                        public_key: metrics_pub_key,
614                        name: bridge_name,
615                    },
616                ))
617            }
618        })
619        .collect()
620        .await;
621
622    results
623}
624
625fn fallback_to_cached_key(
626    metrics_keys: &MetricsPubKeys,
627    url_str: &str,
628    bridge_name: &str,
629) -> Option<(Ed25519PublicKey, AllowedPeer)> {
630    let metrics_keys_read = metrics_keys.read().unwrap();
631    if let Some(cached_key) = metrics_keys_read.get(url_str) {
632        debug!(
633            url_str,
634            "Using cached metrics public key after request failure"
635        );
636        Some((
637            cached_key.clone(),
638            AllowedPeer {
639                public_key: cached_key.clone(),
640                name: bridge_name.to_string(),
641            },
642        ))
643    } else {
644        warn!(
645            url_str,
646            "Failed to fetch public key and no cached key available"
647        );
648        None
649    }
650}
651
652fn append_path_segment(mut url: Url, segment: &str) -> Option<Url> {
653    url.path_segments_mut().ok()?.pop_if_empty().push(segment);
654    Some(url)
655}
656
657// Hashi committee resolver.
658//
659// Reads on-chain state via sui-rpc gRPC, requesting the `json` rendering of
660// each Move object and path-walking into the fields we need — keeps the
661// resolver resilient to hashi adding sibling fields. See
662// `sui-types/src/object/rpc_visitor/mod.rs` for the JSON encoding rules
663// (UID/ID flattened to address strings, u64 as string, vector<u8> as base64,
664// Option<T> as null or bare T).
665
666fn json_struct(v: &JsonValue) -> Option<&std::collections::BTreeMap<String, JsonValue>> {
667    if let Some(JsonKind::StructValue(s)) = &v.kind {
668        Some(&s.fields)
669    } else {
670        None
671    }
672}
673
674fn json_string(v: &JsonValue) -> Option<&str> {
675    if let Some(JsonKind::StringValue(s)) = &v.kind {
676        Some(s)
677    } else {
678        None
679    }
680}
681
682fn json_list(v: &JsonValue) -> Option<&[JsonValue]> {
683    if let Some(JsonKind::ListValue(l)) = &v.kind {
684        Some(&l.values)
685    } else {
686        None
687    }
688}
689
690fn json_field<'a>(v: &'a JsonValue, key: &str) -> Option<&'a JsonValue> {
691    json_struct(v)?.get(key)
692}
693
694fn json_at<'a>(v: &'a JsonValue, path: &[&str]) -> Option<&'a JsonValue> {
695    let mut cur = v;
696    for key in path {
697        cur = json_field(cur, key)?;
698    }
699    Some(cur)
700}
701
702/// Parse a stringly-encoded u64 (Move JSON encodes u64 as a string to preserve precision).
703fn json_u64(v: &JsonValue, name: &str) -> Result<u64> {
704    json_string(v)
705        .with_context(|| format!("{name}: expected JSON string-encoded u64"))?
706        .parse::<u64>()
707        .with_context(|| format!("parsing {name} as u64"))
708}
709
710/// Parse the `pending_epoch_change` field from the CommitteeSet JSON.
711///
712/// On-chain this is `Option<PendingEpochChange>` where the struct contains
713/// an `epoch: u64` field.  The JSON renderer turns `None` into `null` and
714/// `Some(struct)` into a bare struct object, so we may see:
715///   - null                                                   → None
716///   - {"epoch": "1181", "committee_handoff_cert": null, …}   → Some(1181)
717fn json_pending_epoch(v: &JsonValue, name: &str) -> Result<Option<u64>> {
718    match &v.kind {
719        Some(JsonKind::NullValue(_)) | None => Ok(None),
720        Some(JsonKind::StructValue(_)) => {
721            let epoch_val = json_field(v, "epoch")
722                .with_context(|| format!("{name}: struct has no 'epoch' field"))?;
723            Ok(Some(json_u64(epoch_val, &format!("{name}.epoch"))?))
724        }
725        _ => {
726            // Tolerate a bare string-encoded u64 for forward/backward compat.
727            Ok(Some(json_u64(v, name)?))
728        }
729    }
730}
731
732/// Snapshot of the CommitteeSet metadata pulled from one Hashi `get_object` call.
733#[derive(Debug, Clone)]
734struct CommitteeSetSnapshot {
735    epoch: u64,
736    pending_epoch: Option<u64>,
737    members_bag_id: Address,
738    committees_bag_id: Address,
739}
740
741/// A single hashi member resolved from the on-chain `members` Bag. The
742/// `tls_public_key` may be empty for members that registered but haven't yet
743/// called `set_tls_public_key` — callers filter those out.
744#[derive(Debug, Clone, PartialEq, Eq)]
745pub(crate) struct ResolvedHashiMember {
746    pub validator_address: SuiAddress,
747    pub tls_public_key: Vec<u8>,
748}
749
750/// Output of `resolve_hashi_committee`: allowlist contents plus epoch info for
751/// observability metrics.
752#[derive(Debug)]
753struct HashiResolution {
754    epoch: u64,
755    pending_epoch: Option<u64>,
756    peers: Vec<(Ed25519PublicKey, AllowedPeer)>,
757}
758
759/// End-to-end resolve: gRPC reads + BCS decode -> peer allowlist entries.
760async fn resolve_hashi_committee(
761    rpc_url: &str,
762    hashi_object_id: &str,
763    validator_names: &BTreeMap<SuiAddress, String>,
764) -> Result<HashiResolution> {
765    let hashi_object_id = Address::from_str(hashi_object_id)
766        .with_context(|| format!("invalid hashi-object-id '{hashi_object_id}'"))?;
767    let mut client = SuiRpcClient::new(rpc_url.to_owned())
768        .with_context(|| format!("creating sui-rpc client for {rpc_url}"))?;
769
770    let snapshot = get_hashi_committee_snapshot(&mut client, hashi_object_id).await?;
771    debug!(
772        epoch = snapshot.epoch,
773        pending_epoch = ?snapshot.pending_epoch,
774        "fetched hashi committee snapshot"
775    );
776
777    // Union of validator_addresses across active and pending committees; the
778    // set tolerates the expected overlap during reconfig. A missing current
779    // Committee is not an error — at genesis the `committees` Bag is empty
780    // until the first `start_reconfig` runs.
781    let mut active_addrs: std::collections::HashSet<SuiAddress> =
782        match get_committee_validator_addresses(
783            &mut client,
784            snapshot.committees_bag_id,
785            snapshot.epoch,
786        )
787        .await
788        {
789            Ok(addrs) => addrs.into_iter().collect(),
790            Err(e) => {
791                debug!(
792                    epoch = snapshot.epoch,
793                    "no Committee at current epoch (pre-genesis or between reconfigs?): {e:#}"
794                );
795                std::collections::HashSet::new()
796            }
797        };
798    if let Some(next) = snapshot.pending_epoch {
799        match get_committee_validator_addresses(&mut client, snapshot.committees_bag_id, next).await
800        {
801            Ok(addrs) => active_addrs.extend(addrs),
802            Err(e) => warn!(
803                pending_epoch = next,
804                "could not fetch pending committee: {e:#}",
805            ),
806        }
807    }
808
809    // Fetch each member's MemberInfo concurrently. ~100 validators per chain,
810    // bounded concurrency keeps RPC load reasonable without serializing.
811    // sui_rpc::Client is cheap to clone — each clone shares the underlying
812    // tonic Channel so we don't open per-task connections.
813    let members: Vec<ResolvedHashiMember> = stream::iter(active_addrs)
814        .map(|addr| {
815            let mut client = client.clone();
816            let bag = snapshot.members_bag_id;
817            async move {
818                match get_hashi_member_info(&mut client, bag, addr).await {
819                    Ok(m) => Some(m),
820                    Err(e) => {
821                        warn!(addr =% addr, "could not fetch hashi MemberInfo: {e:#}");
822                        None
823                    }
824                }
825            }
826        })
827        .buffer_unordered(16)
828        .filter_map(|x| async move { x })
829        .collect()
830        .await;
831
832    let peers = extract_hashi(members, validator_names);
833
834    Ok(HashiResolution {
835        epoch: snapshot.epoch,
836        pending_epoch: snapshot.pending_epoch,
837        peers,
838    })
839}
840
841/// Filter to members with a valid 32-byte tls_public_key and build AllowedPeer
842/// entries labeled `hashi-<validator name>`.
843fn extract_hashi(
844    members: Vec<ResolvedHashiMember>,
845    validator_names: &BTreeMap<SuiAddress, String>,
846) -> Vec<(Ed25519PublicKey, AllowedPeer)> {
847    members
848        .into_iter()
849        .filter_map(|m| {
850            if m.tls_public_key.len() != 32 {
851                debug!(
852                    addr =% m.validator_address,
853                    "skipping hashi member with empty/invalid tls_public_key"
854                );
855                return None;
856            }
857            let pk = match Ed25519PublicKey::from_bytes(&m.tls_public_key) {
858                Ok(pk) => pk,
859                Err(error) => {
860                    warn!(
861                        addr =% m.validator_address,
862                        ?error,
863                        "invalid tls_public_key bytes for hashi member",
864                    );
865                    return None;
866                }
867            };
868            let name = validator_names
869                .get(&m.validator_address)
870                .cloned()
871                .unwrap_or_else(|| m.validator_address.to_string());
872            let labelled = format!("hashi-{name}");
873            debug!(
874                addr =% m.validator_address,
875                public_key = ?pk,
876                "adding hashi member to allow list as {labelled}",
877            );
878            Some((
879                pk.clone(),
880                AllowedPeer {
881                    name: labelled,
882                    public_key: pk,
883                },
884            ))
885        })
886        .collect()
887}
888
889async fn get_hashi_committee_snapshot(
890    client: &mut SuiRpcClient,
891    hashi_object_id: Address,
892) -> Result<CommitteeSetSnapshot> {
893    let rpc_method = "sui_rpc.LedgerService.GetObject:Hashi";
894    let _timer = JSON_RPC_DURATION
895        .with_label_values(&[rpc_method])
896        .start_timer();
897
898    let response = client
899        .ledger_client()
900        .get_object(
901            GetObjectRequest::new(&hashi_object_id)
902                .with_read_mask(FieldMask::from_paths([Object::path_builder().json()])),
903        )
904        .await
905        .with_context(|| {
906            JSON_RPC_STATE
907                .with_label_values(&[rpc_method, "failed_get"])
908                .inc();
909            format!("get_object failed for Hashi {hashi_object_id}")
910        })?;
911
912    let inner = response.into_inner();
913    let json = inner
914        .object_opt()
915        .and_then(|o| o.json_opt())
916        .with_context(|| {
917            JSON_RPC_STATE
918                .with_label_values(&[rpc_method, "missing_json"])
919                .inc();
920            format!("Hashi {hashi_object_id} response missing JSON rendering")
921        })?;
922
923    let cs = json_field(json, "committee_set").context("missing committee_set in Hashi JSON")?;
924    // Bag.id is a UID; the renderer flattens UID/ID to an address string.
925    let members_bag_id = json_at(cs, &["members", "id"])
926        .and_then(json_string)
927        .context("missing committee_set.members.id")?
928        .parse::<Address>()
929        .context("parsing members bag id as Address")?;
930    let committees_bag_id = json_at(cs, &["committees", "id"])
931        .and_then(json_string)
932        .context("missing committee_set.committees.id")?
933        .parse::<Address>()
934        .context("parsing committees bag id as Address")?;
935    let epoch = json_u64(
936        json_field(cs, "epoch").context("missing committee_set.epoch")?,
937        "committee_set.epoch",
938    )?;
939    let pending_epoch = json_pending_epoch(
940        json_field(cs, "pending_epoch_change")
941            .context("missing committee_set.pending_epoch_change")?,
942        "committee_set.pending_epoch_change",
943    )?;
944
945    JSON_RPC_STATE
946        .with_label_values(&[rpc_method, "success"])
947        .inc();
948
949    Ok(CommitteeSetSnapshot {
950        epoch,
951        pending_epoch,
952        members_bag_id,
953        committees_bag_id,
954    })
955}
956
957async fn get_committee_validator_addresses(
958    client: &mut SuiRpcClient,
959    committees_bag_id: Address,
960    epoch: u64,
961) -> Result<Vec<SuiAddress>> {
962    let rpc_method = "sui_rpc.LedgerService.GetObject:Committee";
963    let _timer = JSON_RPC_DURATION
964        .with_label_values(&[rpc_method])
965        .start_timer();
966
967    let field_id = committees_bag_id.derive_dynamic_child_id(
968        &TypeTag::U64,
969        &bcs::to_bytes(&epoch).expect("u64 always BCS-encodes"),
970    );
971
972    let response = client
973        .ledger_client()
974        .get_object(
975            GetObjectRequest::new(&field_id)
976                .with_read_mask(FieldMask::from_paths([Object::path_builder().json()])),
977        )
978        .await
979        .with_context(|| {
980            JSON_RPC_STATE
981                .with_label_values(&[rpc_method, "failed_get"])
982                .inc();
983            format!("get_object failed for Committee epoch={epoch} under {committees_bag_id}")
984        })?;
985
986    let inner = response.into_inner();
987    let json = inner
988        .object_opt()
989        .and_then(|o| o.json_opt())
990        .with_context(|| {
991            JSON_RPC_STATE
992                .with_label_values(&[rpc_method, "missing_json"])
993                .inc();
994            format!("Committee epoch={epoch} response missing JSON rendering")
995        })?;
996
997    let members = json_at(json, &["value", "members"])
998        .and_then(json_list)
999        .with_context(|| format!("Committee for epoch {epoch} missing value.members[]"))?;
1000
1001    let mut out = Vec::with_capacity(members.len());
1002    for m in members {
1003        let addr_str = json_field(m, "validator_address")
1004            .and_then(json_string)
1005            .context("missing CommitteeMember.validator_address")?;
1006        let addr: SuiAddress = addr_str
1007            .parse()
1008            .with_context(|| format!("parsing CommitteeMember.validator_address: {addr_str}"))?;
1009        out.push(addr);
1010    }
1011
1012    JSON_RPC_STATE
1013        .with_label_values(&[rpc_method, "success"])
1014        .inc();
1015    Ok(out)
1016}
1017
1018async fn get_hashi_member_info(
1019    client: &mut SuiRpcClient,
1020    members_bag_id: Address,
1021    validator_address: SuiAddress,
1022) -> Result<ResolvedHashiMember> {
1023    let rpc_method = "sui_rpc.LedgerService.GetObject:MemberInfo";
1024    let _timer = JSON_RPC_DURATION
1025        .with_label_values(&[rpc_method])
1026        .start_timer();
1027
1028    let key = sui_address_to_sdk_address(validator_address);
1029    let field_id = members_bag_id.derive_dynamic_child_id(
1030        &TypeTag::Address,
1031        &bcs::to_bytes(&key).expect("Address always BCS-encodes"),
1032    );
1033
1034    let response = client
1035        .ledger_client()
1036        .get_object(
1037            GetObjectRequest::new(&field_id)
1038                .with_read_mask(FieldMask::from_paths([Object::path_builder().json()])),
1039        )
1040        .await
1041        .with_context(|| {
1042            JSON_RPC_STATE
1043                .with_label_values(&[rpc_method, "failed_get"])
1044                .inc();
1045            format!("get_object failed for MemberInfo {validator_address}")
1046        })?;
1047
1048    let inner = response.into_inner();
1049    let json = inner
1050        .object_opt()
1051        .and_then(|o| o.json_opt())
1052        .with_context(|| {
1053            JSON_RPC_STATE
1054                .with_label_values(&[rpc_method, "missing_json"])
1055                .inc();
1056            format!("MemberInfo {validator_address} response missing JSON rendering")
1057        })?;
1058
1059    let tls_public_key = match json_at(json, &["value", "tls_public_key"]).and_then(json_string) {
1060        Some(b64) => Base64::decode(b64)
1061            .with_context(|| format!("base64-decode tls_public_key for {validator_address}"))?,
1062        None => Vec::new(),
1063    };
1064
1065    JSON_RPC_STATE
1066        .with_label_values(&[rpc_method, "success"])
1067        .inc();
1068    Ok(ResolvedHashiMember {
1069        validator_address,
1070        tls_public_key,
1071    })
1072}
1073
1074/// Both sui-types and sui-sdk-types use 32-byte addresses; this swaps a
1075/// `SuiAddress` into the `sui_sdk_types::Address` shape that `derive_dynamic_child_id`
1076/// and `bcs::to_bytes` need for key encoding.
1077fn sui_address_to_sdk_address(addr: SuiAddress) -> Address {
1078    Address::new(addr.to_inner())
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    use crate::admin::{CertKeyPair, generate_self_cert};
1085    use serde::{Deserialize, Serialize};
1086    use sui_types::base_types::SuiAddress;
1087    use sui_types::bridge::{BridgeCommitteeSummary, BridgeSummary, MoveTypeCommitteeMember};
1088    use sui_types::sui_system_state::sui_system_state_summary::{
1089        SuiSystemStateSummary, SuiValidatorSummary,
1090    };
1091
1092    /// creates a test that binds our proxy use case to the structure in sui_getLatestSuiSystemState
1093    /// most of the fields are garbage, but we will send the results of the serde process to a private decode
1094    /// function that should always work if the structure is valid for our use
1095    #[test]
1096    fn depend_on_sui_sui_system_state_summary() {
1097        let CertKeyPair(_, client_pub_key) = generate_self_cert("sui".into());
1098        // all fields here just satisfy the field types, with exception to active_validators, we use
1099        // some of those.
1100        let depends_on = SuiSystemStateSummary {
1101            active_validators: vec![SuiValidatorSummary {
1102                network_pubkey_bytes: Vec::from(client_pub_key.as_bytes()),
1103                primary_address: "empty".into(),
1104                worker_address: "empty".into(),
1105                ..Default::default()
1106            }],
1107            ..Default::default()
1108        };
1109
1110        #[derive(Debug, Serialize, Deserialize)]
1111        struct ResponseBody {
1112            result: SuiSystemStateSummary,
1113        }
1114
1115        let r = serde_json::to_string(&ResponseBody { result: depends_on })
1116            .expect("expected to serialize ResponseBody{SuiSystemStateSummary}");
1117
1118        let deserialized = serde_json::from_str::<ResponseBody>(&r)
1119            .expect("expected to deserialize ResponseBody{SuiSystemStateSummary}");
1120
1121        let peers = extract(deserialized.result);
1122        assert_eq!(peers.count(), 1, "peers should have been a length of 1");
1123    }
1124
1125    #[tokio::test]
1126    async fn test_extract_bridge_invalid_bridge_url() {
1127        let summary = BridgeSummary {
1128            committee: BridgeCommitteeSummary {
1129                members: vec![(
1130                    vec![],
1131                    MoveTypeCommitteeMember {
1132                        sui_address: SuiAddress::ZERO,
1133                        http_rest_url: "invalid_bridge_url".as_bytes().to_vec(),
1134                        ..Default::default()
1135                    },
1136                )],
1137                ..Default::default()
1138            },
1139            ..Default::default()
1140        };
1141
1142        let metrics_keys = Arc::new(RwLock::new(HashMap::new()));
1143        {
1144            let mut cache = metrics_keys.write().unwrap();
1145            cache.insert(
1146                "invalid_bridge_url".to_string(),
1147                Ed25519PublicKey::from_bytes(&[1u8; 32]).unwrap(),
1148            );
1149        }
1150        let result = extract_bridge(summary, Arc::new(BTreeMap::new()), metrics_keys.clone()).await;
1151
1152        assert_eq!(
1153            result.len(),
1154            0,
1155            "Should not fall back on cache if invalid bridge url is set"
1156        );
1157    }
1158
1159    #[tokio::test]
1160    async fn test_extract_bridge_interrupted_response() {
1161        let summary = BridgeSummary {
1162            committee: BridgeCommitteeSummary {
1163                members: vec![(
1164                    vec![],
1165                    MoveTypeCommitteeMember {
1166                        sui_address: SuiAddress::ZERO,
1167                        http_rest_url: "https://unresponsive_bridge_url".as_bytes().to_vec(),
1168                        ..Default::default()
1169                    },
1170                )],
1171                ..Default::default()
1172            },
1173            ..Default::default()
1174        };
1175
1176        let metrics_keys = Arc::new(RwLock::new(HashMap::new()));
1177        {
1178            let mut cache = metrics_keys.write().unwrap();
1179            cache.insert(
1180                "https://unresponsive_bridge_url".to_string(),
1181                Ed25519PublicKey::from_bytes(&[1u8; 32]).unwrap(),
1182            );
1183        }
1184        let result = extract_bridge(summary, Arc::new(BTreeMap::new()), metrics_keys.clone()).await;
1185
1186        assert_eq!(
1187            result.len(),
1188            1,
1189            "Should fall back on cache if invalid response occurs"
1190        );
1191        let allowed_peer = &result[0].1;
1192        assert_eq!(
1193            allowed_peer.public_key.as_bytes(),
1194            &[1u8; 32],
1195            "Should fall back to the cached public key"
1196        );
1197
1198        let cache = metrics_keys.read().unwrap();
1199        assert!(
1200            cache.contains_key("https://unresponsive_bridge_url"),
1201            "Cache should still contain the original key"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_append_path_segment() {
1207        let test_cases = vec![
1208            (
1209                "https://example.com",
1210                "metrics_pub_key",
1211                "https://example.com/metrics_pub_key",
1212            ),
1213            (
1214                "https://example.com/api",
1215                "metrics_pub_key",
1216                "https://example.com/api/metrics_pub_key",
1217            ),
1218            (
1219                "https://example.com/",
1220                "metrics_pub_key",
1221                "https://example.com/metrics_pub_key",
1222            ),
1223            (
1224                "https://example.com/api/",
1225                "metrics_pub_key",
1226                "https://example.com/api/metrics_pub_key",
1227            ),
1228            (
1229                "https://example.com:8080",
1230                "metrics_pub_key",
1231                "https://example.com:8080/metrics_pub_key",
1232            ),
1233            (
1234                "https://example.com?param=value",
1235                "metrics_pub_key",
1236                "https://example.com/metrics_pub_key?param=value",
1237            ),
1238            (
1239                "https://example.com:8080/api/v1?param=value",
1240                "metrics_pub_key",
1241                "https://example.com:8080/api/v1/metrics_pub_key?param=value",
1242            ),
1243        ];
1244
1245        for (input_url, segment, expected_output) in test_cases {
1246            let url = Url::parse(input_url).unwrap();
1247            let result = append_path_segment(url, segment);
1248            assert!(
1249                result.is_some(),
1250                "Failed to append segment for URL: {}",
1251                input_url
1252            );
1253            let result_url = result.unwrap();
1254            assert_eq!(
1255                result_url.as_str(),
1256                expected_output,
1257                "Unexpected result for input URL: {}",
1258                input_url
1259            );
1260        }
1261    }
1262
1263    // Hashi resolver tests
1264
1265    fn addr(byte: u8) -> SuiAddress {
1266        // Build a deterministic test SuiAddress from a single discriminator byte.
1267        let mut bytes = [0u8; 32];
1268        bytes[31] = byte;
1269        SuiAddress::from_bytes(bytes).unwrap()
1270    }
1271
1272    /// Generates a real Ed25519 public key. We can't just use `[byte; 32]` because
1273    /// not every 32-byte string decompresses to a valid Ed25519 curve point —
1274    /// `extract_hashi` calls `Ed25519PublicKey::from_bytes` which rejects invalid
1275    /// points, so test inputs have to be genuine keys.
1276    fn fresh_pk_bytes() -> Vec<u8> {
1277        use fastcrypto::ed25519::Ed25519KeyPair;
1278        use fastcrypto::traits::KeyPair;
1279        let kp = Ed25519KeyPair::generate(&mut rand::thread_rng());
1280        kp.public().as_bytes().to_vec()
1281    }
1282
1283    #[test]
1284    fn extract_hashi_keeps_members_with_valid_tls_key() {
1285        let names: BTreeMap<SuiAddress, String> = [
1286            (addr(0xAA), "alice".to_string()),
1287            (addr(0xBB), "bob".to_string()),
1288        ]
1289        .into_iter()
1290        .collect();
1291        let members = vec![
1292            ResolvedHashiMember {
1293                validator_address: addr(0xAA),
1294                tls_public_key: fresh_pk_bytes(),
1295            },
1296            ResolvedHashiMember {
1297                validator_address: addr(0xBB),
1298                tls_public_key: fresh_pk_bytes(),
1299            },
1300        ];
1301
1302        let peers = extract_hashi(members, &names);
1303        assert_eq!(peers.len(), 2);
1304
1305        let names_out: std::collections::HashSet<_> =
1306            peers.iter().map(|(_, p)| p.name.clone()).collect();
1307        assert!(names_out.contains("hashi-alice"));
1308        assert!(names_out.contains("hashi-bob"));
1309    }
1310
1311    #[test]
1312    fn extract_hashi_skips_members_with_empty_tls_key() {
1313        // A member that registered but hasn't called set_tls_public_key yet should
1314        // be silently dropped from the allowlist — they can't authenticate anyway.
1315        let members = vec![
1316            ResolvedHashiMember {
1317                validator_address: addr(0xAA),
1318                tls_public_key: fresh_pk_bytes(),
1319            },
1320            ResolvedHashiMember {
1321                validator_address: addr(0xBB),
1322                tls_public_key: vec![], // not yet set
1323            },
1324        ];
1325        let peers = extract_hashi(members, &BTreeMap::new());
1326        assert_eq!(
1327            peers.len(),
1328            1,
1329            "only the member with a 32-byte key survives"
1330        );
1331    }
1332
1333    #[test]
1334    fn extract_hashi_skips_members_with_wrong_length_tls_key() {
1335        // Defensive: an on-chain bug could in principle let a non-32-byte vector
1336        // through (Move asserts length at set time, but we don't want to depend
1337        // on Move-side invariants for the proxy's auth correctness).
1338        let members = vec![ResolvedHashiMember {
1339            validator_address: addr(0xAA),
1340            tls_public_key: vec![0x01; 16], // half-size
1341        }];
1342        let peers = extract_hashi(members, &BTreeMap::new());
1343        assert!(peers.is_empty());
1344    }
1345
1346    #[test]
1347    fn extract_hashi_falls_back_to_address_label_when_name_missing() {
1348        // Members whose validator address isn't in the cached validator-name map
1349        // (e.g. resolver ran before the sui-validator-set poll finished, or the
1350        // member's validator entry rotated since) should still be allowed — they're
1351        // on chain — but labeled by raw address.
1352        let member_addr = addr(0xCC);
1353        let members = vec![ResolvedHashiMember {
1354            validator_address: member_addr,
1355            tls_public_key: fresh_pk_bytes(),
1356        }];
1357        let peers = extract_hashi(members, &BTreeMap::new());
1358        assert_eq!(peers.len(), 1);
1359        assert!(
1360            peers[0].1.name.starts_with("hashi-0x"),
1361            "expected fallback to address label, got {}",
1362            peers[0].1.name,
1363        );
1364        assert!(peers[0].1.name.contains(&member_addr.to_string()));
1365    }
1366
1367    // --- json_pending_epoch tests ---
1368
1369    /// Helper: build a JsonValue with NullValue kind.
1370    fn jv_null() -> JsonValue {
1371        JsonValue {
1372            kind: Some(JsonKind::NullValue(0)),
1373        }
1374    }
1375
1376    /// Helper: build a JsonValue with StringValue kind.
1377    fn jv_string(s: &str) -> JsonValue {
1378        JsonValue {
1379            kind: Some(JsonKind::StringValue(s.to_string())),
1380        }
1381    }
1382
1383    /// Helper: build a JsonValue with StructValue kind from key-value pairs.
1384    fn jv_struct(fields: Vec<(&str, JsonValue)>) -> JsonValue {
1385        let map: std::collections::BTreeMap<String, JsonValue> = fields
1386            .into_iter()
1387            .map(|(k, v)| (k.to_string(), v))
1388            .collect();
1389        JsonValue {
1390            kind: Some(JsonKind::StructValue(prost_types::Struct { fields: map })),
1391        }
1392    }
1393
1394    #[test]
1395    fn json_pending_epoch_null_returns_none() {
1396        let result = json_pending_epoch(&jv_null(), "test").unwrap();
1397        assert_eq!(result, None);
1398    }
1399
1400    #[test]
1401    fn json_pending_epoch_struct_extracts_epoch() {
1402        let v = jv_struct(vec![
1403            ("epoch", jv_string("1181")),
1404            ("committee_handoff_cert", jv_null()),
1405        ]);
1406        let result = json_pending_epoch(&v, "test").unwrap();
1407        assert_eq!(result, Some(1181));
1408    }
1409
1410    #[test]
1411    fn json_pending_epoch_bare_string_u64() {
1412        // Backward-compat: tolerate a bare string-encoded u64.
1413        let v = jv_string("42");
1414        let result = json_pending_epoch(&v, "test").unwrap();
1415        assert_eq!(result, Some(42));
1416    }
1417
1418    #[test]
1419    fn json_pending_epoch_struct_missing_epoch_field_errors() {
1420        let v = jv_struct(vec![("committee_handoff_cert", jv_null())]);
1421        let result = json_pending_epoch(&v, "test");
1422        assert!(result.is_err());
1423        let msg = format!("{:#}", result.unwrap_err());
1424        assert!(msg.contains("no 'epoch' field"), "unexpected error: {msg}",);
1425    }
1426
1427    #[test]
1428    fn extract_hashi_dedups_pubkey_collision() {
1429        // Two distinct validator_addresses with the same tls_public_key is a
1430        // pathological case (operators reusing keys); the second insertion into
1431        // the HashMap downstream of this fn wins. We just verify extract_hashi
1432        // itself emits both entries and lets the caller's HashMap dedup.
1433        let shared_key = fresh_pk_bytes();
1434        let members = vec![
1435            ResolvedHashiMember {
1436                validator_address: addr(0xAA),
1437                tls_public_key: shared_key.clone(),
1438            },
1439            ResolvedHashiMember {
1440                validator_address: addr(0xBB),
1441                tls_public_key: shared_key,
1442            },
1443        ];
1444        let peers = extract_hashi(members, &BTreeMap::new());
1445        assert_eq!(peers.len(), 2);
1446        // Same pubkey → both entries are dropped into the same HashMap key on
1447        // the consumer side; we confirm shared key here so the test fails loudly
1448        // if we ever silently change that behavior.
1449        assert_eq!(peers[0].0, peers[1].0);
1450    }
1451}