1use 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
55static 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
66static 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
77pub 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
88type ValidatorNames = Arc<RwLock<BTreeMap<SuiAddress, String>>>;
92
93#[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 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 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 pub fn get(&self, key: &Ed25519PublicKey) -> Option<AllowedPeer> {
151 debug!("look for {:?}", key);
152 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 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 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 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 pub fn get_sui_mut(&mut self) -> &mut AllowedPeers {
185 &mut self.sui_nodes
186 }
187
188 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 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 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 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 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
449fn 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 )) }
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 }
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 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 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 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 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 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 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
657fn 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
702fn 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
710fn 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 Ok(Some(json_u64(v, name)?))
728 }
729 }
730}
731
732#[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#[derive(Debug, Clone, PartialEq, Eq)]
745pub(crate) struct ResolvedHashiMember {
746 pub validator_address: SuiAddress,
747 pub tls_public_key: Vec<u8>,
748}
749
750#[derive(Debug)]
753struct HashiResolution {
754 epoch: u64,
755 pending_epoch: Option<u64>,
756 peers: Vec<(Ed25519PublicKey, AllowedPeer)>,
757}
758
759async 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 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 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
841fn 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 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
1074fn 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 #[test]
1096 fn depend_on_sui_sui_system_state_summary() {
1097 let CertKeyPair(_, client_pub_key) = generate_self_cert("sui".into());
1098 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 fn addr(byte: u8) -> SuiAddress {
1266 let mut bytes = [0u8; 32];
1268 bytes[31] = byte;
1269 SuiAddress::from_bytes(bytes).unwrap()
1270 }
1271
1272 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 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![], },
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 let members = vec![ResolvedHashiMember {
1339 validator_address: addr(0xAA),
1340 tls_public_key: vec![0x01; 16], }];
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 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 fn jv_null() -> JsonValue {
1371 JsonValue {
1372 kind: Some(JsonKind::NullValue(0)),
1373 }
1374 }
1375
1376 fn jv_string(s: &str) -> JsonValue {
1378 JsonValue {
1379 kind: Some(JsonKind::StringValue(s.to_string())),
1380 }
1381 }
1382
1383 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 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 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 assert_eq!(peers[0].0, peers[1].0);
1450 }
1451}