1use 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#[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 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 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 pub async fn ping(&self) -> BridgeResult<bool> {
182 if self.base_url.is_none() {
183 return Err(BridgeError::InvalidAuthorityUrl(self.authority.clone()));
184 }
185 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 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 let client = BridgeClient::new(pubkey_bytes.clone(), committee).unwrap();
273 assert!(client.base_url.is_some());
274
275 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 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 authority.base_url = "127.0.0.1:12345".to_string(); 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 authority.base_url = "http://127.256.0.1:12345".to_string(); 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(®istry);
327
328 let mock_handler = BridgeRequestMockHandler::new();
329
330 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 client
361 .request_sign_bridge_action(action.clone())
362 .await
363 .unwrap();
364
365 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 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 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 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 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}