iota_bridge/client/
bridge_client.rs

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