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