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