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