sui_bridge/
config.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::abi::EthBridgeConfig;
5use crate::crypto::BridgeAuthorityKeyPair;
6use crate::error::BridgeError;
7use crate::eth_client::EthClient;
8use crate::metered_eth_provider::new_metered_eth_multi_provider;
9use crate::metrics::BridgeMetrics;
10use crate::sui_client::SuiBridgeClient;
11use crate::types::{BridgeAction, is_route_valid};
12use crate::utils::get_eth_contract_addresses;
13use alloy::primitives::Address as EthAddress;
14use alloy::providers::Provider;
15use anyhow::anyhow;
16use futures::StreamExt;
17use serde::{Deserialize, Serialize};
18use serde_with::serde_as;
19use std::collections::BTreeMap;
20use std::collections::HashSet;
21use std::path::PathBuf;
22use std::str::FromStr;
23use std::sync::Arc;
24use sui_config::Config;
25use sui_keys::keypair_file::read_key;
26use sui_types::base_types::ObjectRef;
27use sui_types::base_types::{ObjectID, SuiAddress};
28use sui_types::bridge::BridgeChainId;
29use sui_types::crypto::KeypairTraits;
30use sui_types::crypto::{NetworkKeyPair, SuiKeyPair, get_key_pair_from_rng};
31use sui_types::digests::{get_mainnet_chain_identifier, get_testnet_chain_identifier};
32use sui_types::event::EventID;
33use sui_types::gas_coin::GasCoin;
34use sui_types::object::Owner;
35use tracing::info;
36
37#[serde_as]
38#[derive(Clone, Debug, Deserialize, Serialize)]
39#[serde(rename_all = "kebab-case")]
40pub struct EthConfig {
41    /// Rpc url for Eth fullnode, used for query stuff.
42    /// @deprecated (use eth_rpc_urls instead)
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub eth_rpc_url: Option<String>,
45    /// Multiple RPC URLs for Eth fullnodes.
46    /// Quorum-based consensus is used across providers for redundancy.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub eth_rpc_urls: Option<Vec<String>>,
49    /// Quorum size for multi-provider consensus. Must be <= number of URLs.
50    #[serde(default = "default_quorum")]
51    pub eth_rpc_quorum: usize,
52    /// Health check interval in seconds for multi-provider.
53    #[serde(default = "default_health_check_interval_secs")]
54    pub eth_health_check_interval_secs: u64,
55    /// The proxy address of SuiBridge
56    pub eth_bridge_proxy_address: String,
57    /// The expected BridgeChainId on Eth side.
58    pub eth_bridge_chain_id: u8,
59    /// The starting block for EthSyncer to monitor eth contracts.
60    /// It is required when `run_client` is true. Usually this is
61    /// the block number when the bridge contracts are deployed.
62    /// When BridgeNode starts, it reads the contract watermark from storage.
63    /// If the watermark is not found, it will start from this fallback block number.
64    /// If the watermark is found, it will start from the watermark.
65    /// this v.s.`eth_contracts_start_block_override`:
66    pub eth_contracts_start_block_fallback: Option<u64>,
67    /// The starting block for EthSyncer to monitor eth contracts. It overrides
68    /// the watermark in storage. This is useful when we want to reprocess the events
69    /// from a specific block number.
70    /// Note: this field has to be reset after starting the BridgeNode, otherwise it will
71    /// reprocess the events from this block number every time it starts.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub eth_contracts_start_block_override: Option<u64>,
74}
75
76fn default_quorum() -> usize {
77    1
78}
79
80fn default_health_check_interval_secs() -> u64 {
81    300 // 5 minutes
82}
83
84impl EthConfig {
85    /// Backwards compatible function to get list of RPC URLs
86    pub fn rpc_urls(&self) -> Vec<String> {
87        if let Some(ref urls) = self.eth_rpc_urls {
88            urls.clone()
89        } else if let Some(ref url) = self.eth_rpc_url {
90            vec![url.clone()]
91        } else {
92            vec![]
93        }
94    }
95}
96
97#[serde_as]
98#[derive(Clone, Debug, Deserialize, Serialize)]
99#[serde(rename_all = "kebab-case")]
100pub struct SuiConfig {
101    /// Rpc url for Sui fullnode, used for query stuff and submit transactions.
102    pub sui_rpc_url: String,
103    /// The expected BridgeChainId on Sui side.
104    pub sui_bridge_chain_id: u8,
105    /// Path of the file where bridge client key (any SuiKeyPair) is stored.
106    /// If `run_client` is true, and this is None, then use `bridge_authority_key_path` as client key.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub bridge_client_key_path: Option<PathBuf>,
109    /// The gas object to use for paying for gas fees for the client. It needs to
110    /// be owned by the address associated with bridge client key. If not set
111    /// and `run_client` is true, it will query and use the gas object with highest
112    /// amount for the account.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub bridge_client_gas_object: Option<ObjectID>,
115    /// Override the last processed EventID for bridge module `bridge`.
116    /// When set, SuiSyncer will start from this cursor (exclusively) instead of the one in storage.
117    /// If the cursor is not found in storage or override, the query will start from genesis.
118    /// Key: sui module, Value: last processed EventID (tx_digest, event_seq).
119    /// Note 1: This field should be rarely used. Only use it when you understand how to follow up.
120    /// Note 2: the EventID needs to be valid, namely it must exist and matches the filter.
121    /// Otherwise, it will miss one event because of fullnode Event query semantics.
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub sui_bridge_module_last_processed_event_id_override: Option<EventID>,
124    /// Override the next sequence number for SuiSyncer
125    /// When set, SuiSyncer will start from this sequence number (exclusively) instead of the one in storage.
126    /// If the sequence number is not found in storage or override, the query will first fallback to the sequence number corresponding to the last processed EventID from the bridge module `bridge` (which in turn can be overridden via `sui_bridge_module_last_processed_event_id_override`) if available, otherwise fallback to 0.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub sui_bridge_next_sequence_number_override: Option<u64>,
129}
130
131#[serde_as]
132#[derive(Debug, Deserialize, Serialize)]
133#[serde(rename_all = "kebab-case")]
134pub struct BridgeNodeConfig {
135    /// The port that the server listens on.
136    pub server_listen_port: u16,
137    /// The port that for metrics server.
138    pub metrics_port: u16,
139    /// Path of the file where bridge authority key (Secp256k1) is stored.
140    pub bridge_authority_key_path: PathBuf,
141    /// Whether to run client. If true, `sui.bridge_client_key_path`
142    /// and `db_path` needs to be provided.
143    pub run_client: bool,
144    /// Path of the client storage. Required when `run_client` is true.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub db_path: Option<PathBuf>,
147    /// A list of approved governance actions. Action in this list will be signed when requested by client.
148    pub approved_governance_actions: Vec<BridgeAction>,
149    /// Sui configuration
150    pub sui: SuiConfig,
151    /// Eth configuration
152    pub eth: EthConfig,
153    /// Network key used for metrics pushing
154    #[serde(default = "default_ed25519_key_pair")]
155    pub metrics_key_pair: NetworkKeyPair,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub metrics: Option<MetricsConfig>,
158
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub watchdog_config: Option<WatchdogConfig>,
161}
162
163pub fn default_ed25519_key_pair() -> NetworkKeyPair {
164    get_key_pair_from_rng(&mut rand::rngs::OsRng).1
165}
166
167#[derive(Debug, Clone, Deserialize, Serialize)]
168#[serde(rename_all = "kebab-case")]
169pub struct MetricsConfig {
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub push_interval_seconds: Option<u64>,
172    pub push_url: String,
173}
174
175#[derive(Debug, Clone, Deserialize, Serialize)]
176#[serde(rename_all = "kebab-case")]
177pub struct WatchdogConfig {
178    /// Total supplies to watch on Sui. Mapping from coin name to coin type tag
179    pub total_supplies: BTreeMap<String, String>,
180}
181
182impl Config for BridgeNodeConfig {}
183
184impl BridgeNodeConfig {
185    pub async fn validate(
186        &self,
187        metrics: Arc<BridgeMetrics>,
188    ) -> anyhow::Result<(BridgeServerConfig, Option<BridgeClientConfig>)> {
189        info!("Starting config validation");
190        if !is_route_valid(
191            BridgeChainId::try_from(self.sui.sui_bridge_chain_id)?,
192            BridgeChainId::try_from(self.eth.eth_bridge_chain_id)?,
193        ) {
194            return Err(anyhow!(
195                "Route between Sui chain id {} and Eth chain id {} is not valid",
196                self.sui.sui_bridge_chain_id,
197                self.eth.eth_bridge_chain_id,
198            ));
199        };
200
201        let bridge_authority_key = match read_key(&self.bridge_authority_key_path, true)? {
202            SuiKeyPair::Secp256k1(key) => key,
203            _ => unreachable!("we required secp256k1 key in `read_key`"),
204        };
205
206        // we do this check here instead of `prepare_for_sui` below because
207        // that is only called when `run_client` is true.
208        let sui_client =
209            Arc::new(SuiBridgeClient::new(&self.sui.sui_rpc_url, metrics.clone()).await?);
210        let bridge_committee = sui_client
211            .get_bridge_committee()
212            .await
213            .map_err(|e| anyhow!("Error getting bridge committee: {:?}", e))?;
214        if !bridge_committee.is_active_member(&bridge_authority_key.public().into()) {
215            return Err(anyhow!(
216                "Bridge authority key is not part of bridge committee"
217            ));
218        }
219
220        let (eth_client, eth_contracts) = self.prepare_for_eth(metrics.clone()).await?;
221        let bridge_summary = sui_client
222            .get_bridge_summary()
223            .await
224            .map_err(|e| anyhow!("Error getting bridge summary: {:?}", e))?;
225        if bridge_summary.chain_id != self.sui.sui_bridge_chain_id {
226            anyhow::bail!(
227                "Bridge chain id mismatch: expected {}, but connected to {}",
228                self.sui.sui_bridge_chain_id,
229                bridge_summary.chain_id
230            );
231        }
232
233        // Validate approved actions that must be governance actions
234        for action in &self.approved_governance_actions {
235            if !action.is_governance_action() {
236                anyhow::bail!(format!(
237                    "{:?}",
238                    BridgeError::ActionIsNotGovernanceAction(Box::new(action.clone()))
239                ));
240            }
241        }
242        let approved_governance_actions = self.approved_governance_actions.clone();
243
244        let bridge_server_config = BridgeServerConfig {
245            key: bridge_authority_key,
246            metrics_port: self.metrics_port,
247            eth_bridge_proxy_address: eth_contracts[0], // the first contract is bridge proxy
248            server_listen_port: self.server_listen_port,
249            sui_client: sui_client.clone(),
250            eth_client: eth_client.clone(),
251            approved_governance_actions,
252        };
253        if !self.run_client {
254            return Ok((bridge_server_config, None));
255        }
256
257        // If client is enabled, prepare client config
258        let (bridge_client_key, client_sui_address, gas_object_ref) =
259            self.prepare_for_sui(sui_client.clone(), metrics).await?;
260
261        let db_path = self
262            .db_path
263            .clone()
264            .ok_or(anyhow!("`db_path` is required when `run_client` is true"))?;
265
266        let bridge_client_config = BridgeClientConfig {
267            sui_address: client_sui_address,
268            key: bridge_client_key,
269            gas_object_ref,
270            metrics_port: self.metrics_port,
271            sui_client: sui_client.clone(),
272            eth_client: eth_client.clone(),
273            db_path,
274            eth_contracts,
275            // in `prepare_for_eth` we check if this is None when `run_client` is true. Safe to unwrap here.
276            eth_contracts_start_block_fallback: self
277                .eth
278                .eth_contracts_start_block_fallback
279                .unwrap(),
280            eth_contracts_start_block_override: self.eth.eth_contracts_start_block_override,
281            sui_bridge_module_last_processed_event_id_override: self
282                .sui
283                .sui_bridge_module_last_processed_event_id_override,
284            sui_bridge_next_sequence_number_override: self
285                .sui
286                .sui_bridge_next_sequence_number_override,
287            sui_bridge_chain_id: self.sui.sui_bridge_chain_id,
288        };
289
290        info!("Config validation complete");
291        Ok((bridge_server_config, Some(bridge_client_config)))
292    }
293
294    async fn prepare_for_eth(
295        &self,
296        metrics: Arc<BridgeMetrics>,
297    ) -> anyhow::Result<(Arc<EthClient>, Vec<EthAddress>)> {
298        info!("Creating Ethereum client provider");
299        let bridge_proxy_address = EthAddress::from_str(&self.eth.eth_bridge_proxy_address)?;
300        let rpc_urls = self.eth.rpc_urls();
301        anyhow::ensure!(
302            !rpc_urls.is_empty(),
303            "At least one Ethereum RPC URL must be provided"
304        );
305
306        let provider = new_metered_eth_multi_provider(
307            rpc_urls.clone(),
308            self.eth.eth_rpc_quorum,
309            self.eth.eth_health_check_interval_secs,
310            metrics.clone(),
311        )
312        .await?;
313
314        let chain_id = provider.get_chain_id().await?;
315        let (
316            committee_address,
317            limiter_address,
318            vault_address,
319            config_address,
320            _weth_address,
321            _usdt_address,
322            _wbtc_address,
323            _lbtc_address,
324        ) = get_eth_contract_addresses(bridge_proxy_address, provider.clone()).await?;
325        let config = EthBridgeConfig::new(config_address, provider.clone());
326
327        if self.run_client && self.eth.eth_contracts_start_block_fallback.is_none() {
328            return Err(anyhow!(
329                "eth_contracts_start_block_fallback is required when run_client is true"
330            ));
331        }
332
333        // If bridge chain id is Eth Mainent or Sepolia, we expect to see chain
334        // identifier to match accordingly.
335        let bridge_chain_id: u8 = config.chainID().call().await?;
336        if self.eth.eth_bridge_chain_id != bridge_chain_id {
337            return Err(anyhow!(
338                "Bridge chain id mismatch: expected {}, but connected to {}",
339                self.eth.eth_bridge_chain_id,
340                bridge_chain_id
341            ));
342        }
343        if bridge_chain_id == BridgeChainId::EthMainnet as u8 && chain_id != 1 {
344            anyhow::bail!("Expected Eth chain id 1, but connected to {}", chain_id);
345        }
346        if bridge_chain_id == BridgeChainId::EthSepolia as u8 && chain_id != 11155111 {
347            anyhow::bail!(
348                "Expected Eth chain id 11155111, but connected to {}",
349                chain_id
350            );
351        }
352        info!(
353            "Connected to Eth chain: {}, Bridge chain id: {}",
354            chain_id, bridge_chain_id,
355        );
356
357        // Filter out zero addresses (can happen due to storage layout mismatch during upgrades)
358        let all_addresses = vec![
359            bridge_proxy_address,
360            committee_address,
361            config_address,
362            limiter_address,
363            vault_address,
364        ];
365        let valid_addresses: Vec<_> = all_addresses
366            .into_iter()
367            .filter(|addr| !addr.is_zero())
368            .collect();
369
370        if valid_addresses.len() < 5 {
371            tracing::warn!(
372                "Some contract addresses are zero - likely storage layout mismatch. \
373                Event watching will be limited. Valid addresses: {:?}",
374                valid_addresses
375            );
376        }
377
378        let eth_client = Arc::new(
379            EthClient::from_provider(provider, HashSet::from_iter(valid_addresses.clone())).await?,
380        );
381        info!("Ethereum client setup complete");
382        Ok((eth_client, valid_addresses))
383    }
384
385    async fn prepare_for_sui(
386        &self,
387        sui_client: Arc<SuiBridgeClient>,
388        metrics: Arc<BridgeMetrics>,
389    ) -> anyhow::Result<(SuiKeyPair, SuiAddress, ObjectRef)> {
390        let bridge_client_key = match &self.sui.bridge_client_key_path {
391            None => read_key(&self.bridge_authority_key_path, true),
392            Some(path) => read_key(path, false),
393        }?;
394
395        // If bridge chain id is Sui Mainent or Testnet, we expect to see chain
396        // identifier to match accordingly.
397        let sui_identifier = sui_client
398            .get_chain_identifier()
399            .await
400            .map_err(|e| anyhow!("Error getting chain identifier from Sui: {:?}", e))?;
401        if self.sui.sui_bridge_chain_id == BridgeChainId::SuiMainnet as u8
402            && sui_identifier != get_mainnet_chain_identifier().to_string()
403        {
404            anyhow::bail!(
405                "Expected sui chain identifier {}, but connected to {}",
406                self.sui.sui_bridge_chain_id,
407                sui_identifier
408            );
409        }
410        if self.sui.sui_bridge_chain_id == BridgeChainId::SuiTestnet as u8
411            && sui_identifier != get_testnet_chain_identifier().to_string()
412        {
413            anyhow::bail!(
414                "Expected sui chain identifier {}, but connected to {}",
415                self.sui.sui_bridge_chain_id,
416                sui_identifier
417            );
418        }
419        info!(
420            "Connected to Sui chain: {}, Bridge chain id: {}",
421            sui_identifier, self.sui.sui_bridge_chain_id,
422        );
423
424        let client_sui_address = SuiAddress::from(&bridge_client_key.public());
425
426        let gas_object_id = match self.sui.bridge_client_gas_object {
427            Some(id) => id,
428            None => {
429                info!("No gas object configured, finding gas object with highest balance");
430                let sui_client = sui_rpc_api::Client::new(&self.sui.sui_rpc_url)?;
431                // Minimum balance for gas object is 10 SUI
432                pick_highest_balance_coin(sui_client, client_sui_address, 10_000_000_000).await?
433            }
434        };
435        let (gas_coin, gas_object_ref, owner) = sui_client
436            .get_gas_data_panic_if_not_gas(gas_object_id)
437            .await;
438        if owner != Owner::AddressOwner(client_sui_address) {
439            return Err(anyhow!(
440                "Gas object {:?} is not owned by bridge client key's associated sui address {:?}, but {:?}",
441                gas_object_id,
442                client_sui_address,
443                owner
444            ));
445        }
446        let balance = gas_coin.value();
447        info!("Gas object balance: {}", balance);
448        metrics.gas_coin_balance.set(balance as i64);
449
450        info!("Sui client setup complete");
451        Ok((bridge_client_key, client_sui_address, gas_object_ref))
452    }
453}
454
455pub struct BridgeServerConfig {
456    pub key: BridgeAuthorityKeyPair,
457    pub server_listen_port: u16,
458    pub eth_bridge_proxy_address: EthAddress,
459    pub metrics_port: u16,
460    pub sui_client: Arc<SuiBridgeClient>,
461    pub eth_client: Arc<EthClient>,
462    /// A list of approved governance actions. Action in this list will be signed when requested by client.
463    pub approved_governance_actions: Vec<BridgeAction>,
464}
465
466pub struct BridgeClientConfig {
467    pub sui_address: SuiAddress,
468    pub key: SuiKeyPair,
469    pub gas_object_ref: ObjectRef,
470    pub metrics_port: u16,
471    pub sui_client: Arc<SuiBridgeClient>,
472    pub eth_client: Arc<EthClient>,
473    pub db_path: PathBuf,
474    pub eth_contracts: Vec<EthAddress>,
475    // See `BridgeNodeConfig` for the explanation of following two fields.
476    pub eth_contracts_start_block_fallback: u64,
477    pub eth_contracts_start_block_override: Option<u64>,
478    pub sui_bridge_module_last_processed_event_id_override: Option<EventID>,
479    pub sui_bridge_next_sequence_number_override: Option<u64>,
480    pub sui_bridge_chain_id: u8,
481}
482
483#[serde_as]
484#[derive(Clone, Debug, Deserialize, Serialize)]
485#[serde(rename_all = "kebab-case")]
486pub struct BridgeCommitteeConfig {
487    pub bridge_authority_port_and_key_path: Vec<(u64, PathBuf)>,
488}
489
490impl Config for BridgeCommitteeConfig {}
491
492pub async fn pick_highest_balance_coin(
493    client: sui_rpc_api::Client,
494    address: SuiAddress,
495    minimal_amount: u64,
496) -> anyhow::Result<ObjectID> {
497    info!("Looking for a suitable gas coin for address {:?}", address);
498
499    // Only look at SUI coins specifically
500    let mut stream = client
501        .list_owned_objects(address, Some(GasCoin::type_()))
502        .boxed();
503
504    let mut coins_checked = 0;
505
506    while let Some(Ok(object)) = stream.next().await {
507        let Ok(coin) = GasCoin::try_from(&object) else {
508            continue;
509        };
510        info!(
511            "Checking coin: {:?}, balance: {}",
512            object.id(),
513            coin.value()
514        );
515        coins_checked += 1;
516
517        // Take the first coin with a sufficient balance
518        if coin.value() >= minimal_amount {
519            info!(
520                "Found suitable gas coin with {} mist (object ID: {:?})",
521                coin.value(),
522                object.id(),
523            );
524            return Ok(object.id());
525        }
526
527        // Only check a small number of coins before giving up
528        if coins_checked >= 1000 {
529            break;
530        }
531    }
532
533    Err(anyhow!(
534        "No suitable gas coin with >= {} mist found for address {:?} after checking {} coins",
535        minimal_amount,
536        address,
537        coins_checked
538    ))
539}
540
541#[derive(Debug, Eq, PartialEq, Clone)]
542pub struct EthContractAddresses {
543    pub sui_bridge: EthAddress,
544    pub bridge_committee: EthAddress,
545    pub bridge_config: EthAddress,
546    pub bridge_limiter: EthAddress,
547    pub bridge_vault: EthAddress,
548}