sui_bridge/client/
bridge_client.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `BridgeClient` talks to BridgeNode.
5
6use crate::crypto::{BridgeAuthorityPublicKeyBytes, verify_signed_bridge_action};
7use crate::error::{BridgeError, BridgeResult};
8use crate::server::APPLICATION_JSON;
9use crate::types::{BridgeAction, BridgeCommittee, VerifiedSignedBridgeAction};
10use fastcrypto::encoding::{Encoding, Hex};
11use fastcrypto::traits::ToFromBytes;
12use std::str::FromStr;
13use std::sync::Arc;
14use url::Url;
15
16// Note: `base_url` is `Option<Url>` because `quorum_map_then_reduce_with_timeout_and_prefs`
17// uses `[]` to get Client based on key. Therefore even when the URL is invalid we need to
18// create a Client instance.
19// TODO: In the future we can consider change `quorum_map_then_reduce_with_timeout_and_prefs`
20// and its callsites to use `get` instead of `[]`.
21#[derive(Clone, Debug)]
22pub struct BridgeClient {
23    inner: reqwest::Client,
24    authority: BridgeAuthorityPublicKeyBytes,
25    committee: Arc<BridgeCommittee>,
26    base_url: Option<Url>,
27}
28
29impl BridgeClient {
30    pub fn new(
31        authority_name: BridgeAuthorityPublicKeyBytes,
32        committee: Arc<BridgeCommittee>,
33    ) -> BridgeResult<Self> {
34        if !committee.is_active_member(&authority_name) {
35            return Err(BridgeError::InvalidBridgeAuthority(authority_name));
36        }
37        // Unwrap safe: we passed the `is_active_member` check above
38        let member = committee.member(&authority_name).unwrap();
39        Ok(Self {
40            inner: reqwest::Client::builder()
41                .timeout(std::time::Duration::from_secs(30))
42                .build()?,
43            authority: authority_name.clone(),
44            base_url: Url::from_str(&member.base_url).ok(),
45            committee,
46        })
47    }
48
49    #[cfg(test)]
50    pub fn update_committee(&mut self, committee: Arc<BridgeCommittee>) {
51        self.committee = committee;
52    }
53
54    // Important: the paths need to match the ones in server/mod.rs
55    fn bridge_action_to_path(event: &BridgeAction) -> String {
56        match event {
57            BridgeAction::SuiToEthBridgeAction(e) => format!(
58                "sign/bridge_tx/sui/eth/{}/{}",
59                e.sui_tx_digest, e.sui_tx_event_index
60            ),
61            BridgeAction::SuiToEthTokenTransfer(_action) => format!(
62                "/sign/bridge_action/sui/eth/{source_chain}/{message_type}/{bridge_seq_num}",
63                source_chain = event.chain_id() as u8,
64                message_type = event.action_type() as u8,
65                bridge_seq_num = event.seq_number(),
66            ),
67            BridgeAction::EthToSuiBridgeAction(e) => format!(
68                "sign/bridge_tx/eth/sui/{}/{}",
69                Hex::encode(e.eth_tx_hash.0),
70                e.eth_event_index
71            ),
72            BridgeAction::BlocklistCommitteeAction(a) => {
73                let chain_id = (a.chain_id as u8).to_string();
74                let nonce = a.nonce.to_string();
75                let type_ = (a.blocklist_type as u8).to_string();
76                let keys = a
77                    .members_to_update
78                    .iter()
79                    .map(|k| Hex::encode(k.as_bytes()))
80                    .collect::<Vec<_>>()
81                    .join(",");
82                format!("sign/update_committee_blocklist/{chain_id}/{nonce}/{type_}/{keys}")
83            }
84            BridgeAction::EmergencyAction(a) => {
85                let chain_id = (a.chain_id as u8).to_string();
86                let nonce = a.nonce.to_string();
87                let type_ = (a.action_type as u8).to_string();
88                format!("sign/emergency_button/{chain_id}/{nonce}/{type_}")
89            }
90            BridgeAction::LimitUpdateAction(a) => {
91                let chain_id = (a.chain_id as u8).to_string();
92                let nonce = a.nonce.to_string();
93                let sending_chain_id = (a.sending_chain_id as u8).to_string();
94                let new_usd_limit = a.new_usd_limit.to_string();
95                format!("sign/update_limit/{chain_id}/{nonce}/{sending_chain_id}/{new_usd_limit}")
96            }
97            BridgeAction::AssetPriceUpdateAction(a) => {
98                let chain_id = (a.chain_id as u8).to_string();
99                let nonce = a.nonce.to_string();
100                let token_id = a.token_id.to_string();
101                let new_usd_price = a.new_usd_price.to_string();
102                format!("sign/update_asset_price/{chain_id}/{nonce}/{token_id}/{new_usd_price}")
103            }
104            BridgeAction::EvmContractUpgradeAction(a) => {
105                let chain_id = (a.chain_id as u8).to_string();
106                let nonce = a.nonce.to_string();
107                let proxy_address = Hex::encode(a.proxy_address.as_bytes());
108                let new_impl_address = Hex::encode(a.new_impl_address.as_bytes());
109                let path = format!(
110                    "sign/upgrade_evm_contract/{chain_id}/{nonce}/{proxy_address}/{new_impl_address}"
111                );
112                if a.call_data.is_empty() {
113                    path
114                } else {
115                    let call_data = Hex::encode(a.call_data.clone());
116                    format!("{}/{}", path, call_data)
117                }
118            }
119            BridgeAction::AddTokensOnSuiAction(a) => {
120                let chain_id = (a.chain_id as u8).to_string();
121                let nonce = a.nonce.to_string();
122                let native = if a.native { "1" } else { "0" };
123                let token_ids = a
124                    .token_ids
125                    .iter()
126                    .map(|id| id.to_string())
127                    .collect::<Vec<_>>()
128                    .join(",");
129                let token_type_names = a
130                    .token_type_names
131                    .iter()
132                    .map(|name| name.to_canonical_string(true))
133                    .collect::<Vec<_>>()
134                    .join(",");
135                let token_prices = a
136                    .token_prices
137                    .iter()
138                    .map(|price| price.to_string())
139                    .collect::<Vec<_>>()
140                    .join(",");
141                format!(
142                    "sign/add_tokens_on_sui/{chain_id}/{nonce}/{native}/{token_ids}/{token_type_names}/{token_prices}"
143                )
144            }
145            BridgeAction::AddTokensOnEvmAction(a) => {
146                let chain_id = (a.chain_id as u8).to_string();
147                let nonce = a.nonce.to_string();
148                let native = if a.native { "1" } else { "0" };
149                let token_ids = a
150                    .token_ids
151                    .iter()
152                    .map(|id| id.to_string())
153                    .collect::<Vec<_>>()
154                    .join(",");
155                let token_addresses = a
156                    .token_addresses
157                    .iter()
158                    .map(|name| format!("{:?}", name))
159                    .collect::<Vec<_>>()
160                    .join(",");
161                let token_sui_decimals = a
162                    .token_sui_decimals
163                    .iter()
164                    .map(|id| id.to_string())
165                    .collect::<Vec<_>>()
166                    .join(",");
167                let token_prices = a
168                    .token_prices
169                    .iter()
170                    .map(|price| price.to_string())
171                    .collect::<Vec<_>>()
172                    .join(",");
173                format!(
174                    "sign/add_tokens_on_evm/{chain_id}/{nonce}/{native}/{token_ids}/{token_addresses}/{token_sui_decimals}/{token_prices}"
175                )
176            }
177        }
178    }
179
180    // Returns Ok(true) if the server is up and running
181    pub async fn ping(&self) -> BridgeResult<bool> {
182        if self.base_url.is_none() {
183            return Err(BridgeError::InvalidAuthorityUrl(self.authority.clone()));
184        }
185        // Unwrap safe: checked `self.base_url.is_none()` above
186        let url = self.base_url.clone().unwrap();
187        Ok(self
188            .inner
189            .get(url)
190            .header(reqwest::header::ACCEPT, APPLICATION_JSON)
191            .send()
192            .await?
193            .error_for_status()
194            .is_ok())
195    }
196
197    pub async fn request_sign_bridge_action(
198        &self,
199        action: BridgeAction,
200    ) -> BridgeResult<VerifiedSignedBridgeAction> {
201        if self.base_url.is_none() {
202            return Err(BridgeError::InvalidAuthorityUrl(self.authority.clone()));
203        }
204        // Unwrap safe: checked `self.base_url.is_none()` above
205        let url = self
206            .base_url
207            .clone()
208            .unwrap()
209            .join(&Self::bridge_action_to_path(&action))?;
210        let resp = self
211            .inner
212            .get(url)
213            .header(reqwest::header::ACCEPT, APPLICATION_JSON)
214            .send()
215            .await?;
216        if !resp.status().is_success() {
217            let error_status = format!("{:?}", resp.error_for_status_ref());
218            let resp_text = resp.text().await?;
219            return match resp_text {
220                text if text.contains(&format!("{:?}", BridgeError::TxNotFinalized)) => {
221                    Err(BridgeError::TxNotFinalized)
222                }
223                _ => Err(BridgeError::RestAPIError(format!(
224                    "request_sign_bridge_action failed with status {:?}: {:?}",
225                    error_status, resp_text
226                ))),
227            };
228        }
229        let signed_bridge_action = resp.json().await?;
230        verify_signed_bridge_action(
231            &action,
232            signed_bridge_action,
233            &self.authority,
234            &self.committee,
235        )
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::test_utils::run_mock_bridge_server;
243    use crate::{
244        abi::EthToSuiTokenBridgeV1,
245        crypto::BridgeAuthoritySignInfo,
246        events::EmittedSuiToEthTokenBridgeV1,
247        server::mock_handler::BridgeRequestMockHandler,
248        test_utils::{get_test_authority_and_key, get_test_sui_to_eth_bridge_action},
249        types::SignedBridgeAction,
250    };
251    use ethers::types::Address as EthAddress;
252    use ethers::types::TxHash;
253    use fastcrypto::hash::{HashFunction, Keccak256};
254    use fastcrypto::traits::KeyPair;
255    use prometheus::Registry;
256    use sui_types::TypeTag;
257    use sui_types::bridge::{BridgeChainId, TOKEN_ID_BTC, TOKEN_ID_USDT};
258    use sui_types::{base_types::SuiAddress, crypto::get_key_pair, digests::TransactionDigest};
259
260    #[tokio::test]
261    async fn test_bridge_client() {
262        telemetry_subscribers::init_for_testing();
263
264        let (mut authority, pubkey, _) = get_test_authority_and_key(10000, 12345);
265
266        let pubkey_bytes = BridgeAuthorityPublicKeyBytes::from(&pubkey);
267        let committee = Arc::new(BridgeCommittee::new(vec![authority.clone()]).unwrap());
268        let action =
269            get_test_sui_to_eth_bridge_action(None, Some(1), Some(1), Some(100), None, None, None);
270
271        // Ok
272        let client = BridgeClient::new(pubkey_bytes.clone(), committee).unwrap();
273        assert!(client.base_url.is_some());
274
275        // Ok
276        authority.base_url = "https://foo.suibridge.io".to_string();
277        let committee = Arc::new(BridgeCommittee::new(vec![authority.clone()]).unwrap());
278        let client = BridgeClient::new(pubkey_bytes.clone(), committee.clone()).unwrap();
279        assert!(client.base_url.is_some());
280
281        // Err, not in committee
282        let (_, kp2): (_, fastcrypto::secp256k1::Secp256k1KeyPair) = get_key_pair();
283        let pubkey2_bytes = BridgeAuthorityPublicKeyBytes::from(kp2.public());
284        let err = BridgeClient::new(pubkey2_bytes, committee.clone()).unwrap_err();
285        assert!(matches!(err, BridgeError::InvalidBridgeAuthority(_)));
286
287        // invalid base url
288        authority.base_url = "127.0.0.1:12345".to_string(); // <-- bad, missing http://
289        let committee = Arc::new(BridgeCommittee::new(vec![authority.clone()]).unwrap());
290        let client = BridgeClient::new(pubkey_bytes.clone(), committee.clone()).unwrap();
291        assert!(client.base_url.is_none());
292        assert!(matches!(
293            client.ping().await.unwrap_err(),
294            BridgeError::InvalidAuthorityUrl(_)
295        ));
296        assert!(matches!(
297            client
298                .request_sign_bridge_action(action.clone())
299                .await
300                .unwrap_err(),
301            BridgeError::InvalidAuthorityUrl(_)
302        ));
303
304        // invalid base url
305        authority.base_url = "http://127.256.0.1:12345".to_string(); // <-- bad, invalid ipv4 address
306        let committee = Arc::new(BridgeCommittee::new(vec![authority.clone()]).unwrap());
307        let client = BridgeClient::new(pubkey_bytes, committee.clone()).unwrap();
308        assert!(client.base_url.is_none());
309        assert!(matches!(
310            client.ping().await.unwrap_err(),
311            BridgeError::InvalidAuthorityUrl(_)
312        ));
313        assert!(matches!(
314            client
315                .request_sign_bridge_action(action.clone())
316                .await
317                .unwrap_err(),
318            BridgeError::InvalidAuthorityUrl(_)
319        ));
320    }
321
322    #[tokio::test]
323    async fn test_bridge_client_request_sign_action() {
324        telemetry_subscribers::init_for_testing();
325        let registry = Registry::new();
326        mysten_metrics::init_metrics(&registry);
327
328        let mock_handler = BridgeRequestMockHandler::new();
329
330        // start server
331        let (_handles, ports) = run_mock_bridge_server(vec![mock_handler.clone()]);
332
333        let port = ports[0];
334
335        let (authority, _pubkey, secret) = get_test_authority_and_key(5000, port);
336        let (authority2, _pubkey2, secret2) = get_test_authority_and_key(5000, port - 1);
337
338        let committee = BridgeCommittee::new(vec![authority.clone(), authority2.clone()]).unwrap();
339
340        let mut client =
341            BridgeClient::new(authority.pubkey_bytes(), Arc::new(committee.clone())).unwrap();
342
343        let tx_digest = TransactionDigest::random();
344        let event_idx = 4;
345
346        let action = get_test_sui_to_eth_bridge_action(
347            Some(tx_digest),
348            Some(event_idx),
349            Some(1),
350            Some(100),
351            None,
352            None,
353            None,
354        );
355        let sig = BridgeAuthoritySignInfo::new(&action, &secret);
356        let signed_event = SignedBridgeAction::new_from_data_and_sig(action.clone(), sig.clone());
357        mock_handler.add_sui_event_response(tx_digest, event_idx, Ok(signed_event.clone()), None);
358
359        // success
360        client
361            .request_sign_bridge_action(action.clone())
362            .await
363            .unwrap();
364
365        // mismatched action would fail, this could happen when the authority fetched the wrong event
366        let action2 = get_test_sui_to_eth_bridge_action(
367            Some(tx_digest),
368            Some(event_idx),
369            Some(2),
370            Some(200),
371            None,
372            None,
373            None,
374        );
375        let wrong_sig = BridgeAuthoritySignInfo::new(&action2, &secret);
376        let wrong_signed_action =
377            SignedBridgeAction::new_from_data_and_sig(action2.clone(), wrong_sig.clone());
378        mock_handler.add_sui_event_response(tx_digest, event_idx, Ok(wrong_signed_action), None);
379        let err = client
380            .request_sign_bridge_action(action.clone())
381            .await
382            .unwrap_err();
383        assert!(matches!(err, BridgeError::MismatchedAction));
384
385        // The action matches but the signature is wrong, fail
386        let wrong_signed_action =
387            SignedBridgeAction::new_from_data_and_sig(action.clone(), wrong_sig);
388        mock_handler.add_sui_event_response(tx_digest, event_idx, Ok(wrong_signed_action), None);
389        let err = client
390            .request_sign_bridge_action(action.clone())
391            .await
392            .unwrap_err();
393        assert!(matches!(
394            err,
395            BridgeError::InvalidBridgeAuthoritySignature(..)
396        ));
397
398        // sig from blocklisted authority would fail
399        let mut authority_blocklisted = authority.clone();
400        authority_blocklisted.is_blocklisted = true;
401        let committee2 = Arc::new(
402            BridgeCommittee::new(vec![authority_blocklisted.clone(), authority2.clone()]).unwrap(),
403        );
404        client.update_committee(committee2);
405        mock_handler.add_sui_event_response(tx_digest, event_idx, Ok(signed_event), None);
406
407        let err = client
408            .request_sign_bridge_action(action.clone())
409            .await
410            .unwrap_err();
411        assert!(
412            matches!(err, BridgeError::InvalidBridgeAuthority(pk) if pk == authority_blocklisted.pubkey_bytes()),
413        );
414
415        client.update_committee(committee.into());
416
417        // signed by a different authority in committee would fail
418        let sig2 = BridgeAuthoritySignInfo::new(&action, &secret2);
419        let signed_event2 = SignedBridgeAction::new_from_data_and_sig(action.clone(), sig2.clone());
420        mock_handler.add_sui_event_response(tx_digest, event_idx, Ok(signed_event2), None);
421        let err = client
422            .request_sign_bridge_action(action.clone())
423            .await
424            .unwrap_err();
425        assert!(matches!(err, BridgeError::MismatchedAuthoritySigner));
426
427        // signed by a different key, not in committee, would fail
428        let (_, kp3): (_, fastcrypto::secp256k1::Secp256k1KeyPair) = get_key_pair();
429        let secret3 = Arc::pin(kp3);
430        let sig3 = BridgeAuthoritySignInfo::new(&action, &secret3);
431        let signed_event3 = SignedBridgeAction::new_from_data_and_sig(action.clone(), sig3);
432        mock_handler.add_sui_event_response(tx_digest, event_idx, Ok(signed_event3), None);
433        let err = client
434            .request_sign_bridge_action(action.clone())
435            .await
436            .unwrap_err();
437        assert!(matches!(err, BridgeError::MismatchedAuthoritySigner));
438    }
439
440    #[test]
441    fn test_bridge_action_path_regression_tests() {
442        let sui_tx_digest = TransactionDigest::random();
443        let sui_tx_event_index = 5;
444        let action = BridgeAction::SuiToEthBridgeAction(crate::types::SuiToEthBridgeAction {
445            sui_tx_digest,
446            sui_tx_event_index,
447            sui_bridge_event: EmittedSuiToEthTokenBridgeV1 {
448                sui_chain_id: BridgeChainId::SuiCustom,
449                nonce: 1,
450                sui_address: SuiAddress::random_for_testing_only(),
451                eth_chain_id: BridgeChainId::EthSepolia,
452                eth_address: EthAddress::random(),
453                token_id: TOKEN_ID_USDT,
454                amount_sui_adjusted: 1,
455            },
456        });
457        assert_eq!(
458            BridgeClient::bridge_action_to_path(&action),
459            format!(
460                "sign/bridge_tx/sui/eth/{}/{}",
461                sui_tx_digest, sui_tx_event_index
462            )
463        );
464
465        let eth_tx_hash = TxHash::random();
466        let eth_event_index = 6;
467        let action = BridgeAction::EthToSuiBridgeAction(crate::types::EthToSuiBridgeAction {
468            eth_tx_hash,
469            eth_event_index,
470            eth_bridge_event: EthToSuiTokenBridgeV1 {
471                eth_chain_id: BridgeChainId::EthSepolia,
472                nonce: 1,
473                eth_address: EthAddress::random(),
474                sui_chain_id: BridgeChainId::SuiCustom,
475                sui_address: SuiAddress::random_for_testing_only(),
476                token_id: TOKEN_ID_USDT,
477                sui_adjusted_amount: 1,
478            },
479        });
480
481        assert_eq!(
482            BridgeClient::bridge_action_to_path(&action),
483            format!(
484                "sign/bridge_tx/eth/sui/{}/{}",
485                Hex::encode(eth_tx_hash.0),
486                eth_event_index
487            )
488        );
489
490        let pub_key_bytes = BridgeAuthorityPublicKeyBytes::from_bytes(
491            &Hex::decode("027f1178ff417fc9f5b8290bd8876f0a157a505a6c52db100a8492203ddd1d4279")
492                .unwrap(),
493        )
494        .unwrap();
495
496        let action =
497            BridgeAction::BlocklistCommitteeAction(crate::types::BlocklistCommitteeAction {
498                chain_id: BridgeChainId::EthSepolia,
499                nonce: 1,
500                blocklist_type: crate::types::BlocklistType::Blocklist,
501                members_to_update: vec![pub_key_bytes.clone()],
502            });
503        assert_eq!(
504            BridgeClient::bridge_action_to_path(&action),
505            "sign/update_committee_blocklist/11/1/0/027f1178ff417fc9f5b8290bd8876f0a157a505a6c52db100a8492203ddd1d4279",
506        );
507        let pub_key_bytes2 = BridgeAuthorityPublicKeyBytes::from_bytes(
508            &Hex::decode("02321ede33d2c2d7a8a152f275a1484edef2098f034121a602cb7d767d38680aa4")
509                .unwrap(),
510        )
511        .unwrap();
512        let action =
513            BridgeAction::BlocklistCommitteeAction(crate::types::BlocklistCommitteeAction {
514                chain_id: BridgeChainId::EthSepolia,
515                nonce: 1,
516                blocklist_type: crate::types::BlocklistType::Blocklist,
517                members_to_update: vec![pub_key_bytes.clone(), pub_key_bytes2.clone()],
518            });
519        assert_eq!(
520            BridgeClient::bridge_action_to_path(&action),
521            "sign/update_committee_blocklist/11/1/0/027f1178ff417fc9f5b8290bd8876f0a157a505a6c52db100a8492203ddd1d4279,02321ede33d2c2d7a8a152f275a1484edef2098f034121a602cb7d767d38680aa4",
522        );
523
524        let action = BridgeAction::EmergencyAction(crate::types::EmergencyAction {
525            chain_id: BridgeChainId::SuiCustom,
526            nonce: 5,
527            action_type: crate::types::EmergencyActionType::Pause,
528        });
529        assert_eq!(
530            BridgeClient::bridge_action_to_path(&action),
531            "sign/emergency_button/2/5/0",
532        );
533
534        let action = BridgeAction::LimitUpdateAction(crate::types::LimitUpdateAction {
535            chain_id: BridgeChainId::SuiCustom,
536            nonce: 10,
537            sending_chain_id: BridgeChainId::EthCustom,
538            new_usd_limit: 100,
539        });
540        assert_eq!(
541            BridgeClient::bridge_action_to_path(&action),
542            "sign/update_limit/2/10/12/100",
543        );
544
545        let action = BridgeAction::AssetPriceUpdateAction(crate::types::AssetPriceUpdateAction {
546            chain_id: BridgeChainId::SuiCustom,
547            nonce: 8,
548            token_id: TOKEN_ID_BTC,
549            new_usd_price: 100_000_000,
550        });
551        assert_eq!(
552            BridgeClient::bridge_action_to_path(&action),
553            "sign/update_asset_price/2/8/1/100000000",
554        );
555
556        let action =
557            BridgeAction::EvmContractUpgradeAction(crate::types::EvmContractUpgradeAction {
558                nonce: 123,
559                chain_id: BridgeChainId::EthCustom,
560                proxy_address: EthAddress::repeat_byte(6),
561                new_impl_address: EthAddress::repeat_byte(9),
562                call_data: vec![],
563            });
564        assert_eq!(
565            BridgeClient::bridge_action_to_path(&action),
566            "sign/upgrade_evm_contract/12/123/0606060606060606060606060606060606060606/0909090909090909090909090909090909090909",
567        );
568
569        let function_signature = "initializeV2()";
570        let selector = &Keccak256::digest(function_signature).digest[0..4];
571        let mut call_data = selector.to_vec();
572        let action =
573            BridgeAction::EvmContractUpgradeAction(crate::types::EvmContractUpgradeAction {
574                nonce: 123,
575                chain_id: BridgeChainId::EthCustom,
576                proxy_address: EthAddress::repeat_byte(6),
577                new_impl_address: EthAddress::repeat_byte(9),
578                call_data: call_data.clone(),
579            });
580        assert_eq!(
581            BridgeClient::bridge_action_to_path(&action),
582            "sign/upgrade_evm_contract/12/123/0606060606060606060606060606060606060606/0909090909090909090909090909090909090909/5cd8a76b",
583        );
584
585        call_data.extend(ethers::abi::encode(&[ethers::abi::Token::Uint(42.into())]));
586        let action =
587            BridgeAction::EvmContractUpgradeAction(crate::types::EvmContractUpgradeAction {
588                nonce: 123,
589                chain_id: BridgeChainId::EthCustom,
590                proxy_address: EthAddress::repeat_byte(6),
591                new_impl_address: EthAddress::repeat_byte(9),
592                call_data,
593            });
594        assert_eq!(
595            BridgeClient::bridge_action_to_path(&action),
596            "sign/upgrade_evm_contract/12/123/0606060606060606060606060606060606060606/0909090909090909090909090909090909090909/5cd8a76b000000000000000000000000000000000000000000000000000000000000002a",
597        );
598
599        let action = BridgeAction::AddTokensOnSuiAction(crate::types::AddTokensOnSuiAction {
600            nonce: 3,
601            chain_id: BridgeChainId::SuiCustom,
602            native: false,
603            token_ids: vec![99, 100, 101],
604            token_type_names: vec![
605                TypeTag::from_str("0x0000000000000000000000000000000000000000000000000000000000000abc::my_coin::MyCoin1").unwrap(),
606                TypeTag::from_str("0x0000000000000000000000000000000000000000000000000000000000000abc::my_coin::MyCoin2").unwrap(),
607                TypeTag::from_str("0x0000000000000000000000000000000000000000000000000000000000000abc::my_coin::MyCoin3").unwrap(),
608            ],
609            token_prices: vec![1_000_000_000, 2_000_000_000, 3_000_000_000],
610        });
611        assert_eq!(
612            BridgeClient::bridge_action_to_path(&action),
613            "sign/add_tokens_on_sui/2/3/0/99,100,101/0x0000000000000000000000000000000000000000000000000000000000000abc::my_coin::MyCoin1,0x0000000000000000000000000000000000000000000000000000000000000abc::my_coin::MyCoin2,0x0000000000000000000000000000000000000000000000000000000000000abc::my_coin::MyCoin3/1000000000,2000000000,3000000000",
614        );
615
616        let action = BridgeAction::AddTokensOnEvmAction(crate::types::AddTokensOnEvmAction {
617            nonce: 0,
618            chain_id: BridgeChainId::EthCustom,
619            native: true,
620            token_ids: vec![99, 100, 101],
621            token_addresses: vec![
622                EthAddress::repeat_byte(1),
623                EthAddress::repeat_byte(2),
624                EthAddress::repeat_byte(3),
625            ],
626            token_sui_decimals: vec![5, 6, 7],
627            token_prices: vec![1_000_000_000, 2_000_000_000, 3_000_000_000],
628        });
629        assert_eq!(
630            BridgeClient::bridge_action_to_path(&action),
631            "sign/add_tokens_on_evm/12/0/1/99,100,101/0x0101010101010101010101010101010101010101,0x0202020202020202020202020202020202020202,0x0303030303030303030303030303030303030303/5,6,7/1000000000,2000000000,3000000000",
632        );
633    }
634}