1use crate::account::BuilderAccount;
2use crate::config::{get_contract_config, BuilderConfig, ContractConfig};
3use crate::error::RelayError;
4use crate::types::{
5 NonceResponse, RelayerTransactionResponse, SafeTransaction, SafeTx, TransactionStatusResponse,
6 WalletType,
7};
8use alloy::hex;
9use alloy::network::TransactionBuilder;
10use alloy::primitives::{keccak256, Address, Bytes, U256};
11use alloy::providers::{Provider, ProviderBuilder};
12use alloy::rpc::types::TransactionRequest;
13use alloy::signers::Signer;
14use alloy::sol_types::{Eip712Domain, SolCall, SolStruct, SolValue};
15use reqwest::Client;
16use serde::Serialize;
17use std::time::{Duration, Instant};
18use url::Url;
19
20const SAFE_INIT_CODE_HASH: &str =
22 "2bce2127ff07fb632d16c8347c4ebf501f4841168bed00d9e6ef715ddb6fcecf";
23
24const PROXY_INIT_CODE_HASH: &str =
26 "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b";
27
28#[derive(Debug, Clone)]
29pub struct RelayClient {
30 client: Client,
31 base_url: Url,
32 chain_id: u64,
33 account: Option<BuilderAccount>,
34 contract_config: ContractConfig,
35 wallet_type: WalletType,
36}
37
38impl RelayClient {
39 pub fn new(
41 base_url: &str,
42 chain_id: u64,
43 private_key: impl Into<String>,
44 config: Option<BuilderConfig>,
45 ) -> Result<Self, RelayError> {
46 let account = BuilderAccount::new(private_key, config)?;
47 Self::builder(base_url, chain_id)?
48 .with_account(account)
49 .build()
50 }
51
52 pub fn builder(base_url: &str, chain_id: u64) -> Result<RelayClientBuilder, RelayError> {
54 RelayClientBuilder::new(base_url, chain_id)
55 }
56
57 pub fn from_account(
59 base_url: &str,
60 chain_id: u64,
61 account: BuilderAccount,
62 ) -> Result<Self, RelayError> {
63 Self::builder(base_url, chain_id)?
64 .with_account(account)
65 .build()
66 }
67
68 pub fn address(&self) -> Option<Address> {
69 self.account.as_ref().map(|a| a.address())
70 }
71
72 pub async fn ping(&self) -> Result<Duration, RelayError> {
89 let start = Instant::now();
90 let response = self.client.get(self.base_url.clone()).send().await?;
91 let latency = start.elapsed();
92
93 if !response.status().is_success() {
94 let text = response.text().await?;
95 return Err(RelayError::Api(format!("Ping failed: {}", text)));
96 }
97
98 Ok(latency)
99 }
100
101 pub async fn get_nonce(&self, address: Address) -> Result<u64, RelayError> {
102 let url = self.base_url.join(&format!(
103 "nonce?address={}&type={}",
104 address,
105 self.wallet_type.as_str()
106 ))?;
107 let resp = self.client.get(url).send().await?;
108
109 if !resp.status().is_success() {
110 let text = resp.text().await?;
111 return Err(RelayError::Api(format!("get_nonce failed: {}", text)));
112 }
113
114 let data = resp.json::<NonceResponse>().await?;
115 Ok(data.nonce)
116 }
117
118 pub async fn get_transaction(
119 &self,
120 transaction_id: &str,
121 ) -> Result<TransactionStatusResponse, RelayError> {
122 let url = self
123 .base_url
124 .join(&format!("transaction?id={}", transaction_id))?;
125 let resp = self.client.get(url).send().await?;
126
127 if !resp.status().is_success() {
128 let text = resp.text().await?;
129 return Err(RelayError::Api(format!("get_transaction failed: {}", text)));
130 }
131
132 resp.json::<TransactionStatusResponse>()
133 .await
134 .map_err(Into::into)
135 }
136
137 pub async fn get_deployed(&self, safe_address: Address) -> Result<bool, RelayError> {
138 #[derive(serde::Deserialize)]
139 struct DeployedResponse {
140 deployed: bool,
141 }
142 let url = self
143 .base_url
144 .join(&format!("deployed?address={}", safe_address))?;
145 let resp = self.client.get(url).send().await?;
146
147 if !resp.status().is_success() {
148 let text = resp.text().await?;
149 return Err(RelayError::Api(format!("get_deployed failed: {}", text)));
150 }
151
152 let data = resp.json::<DeployedResponse>().await?;
153 Ok(data.deployed)
154 }
155
156 fn derive_safe_address(&self, owner: Address) -> Address {
157 let salt = keccak256(owner.abi_encode());
158 let init_code_hash = hex::decode(SAFE_INIT_CODE_HASH).unwrap();
159
160 let mut input = Vec::new();
162 input.push(0xff);
163 input.extend_from_slice(self.contract_config.safe_factory.as_slice());
164 input.extend_from_slice(salt.as_slice());
165 input.extend_from_slice(&init_code_hash);
166
167 let hash = keccak256(input);
168 Address::from_slice(&hash[12..])
169 }
170
171 pub fn get_expected_safe(&self) -> Result<Address, RelayError> {
172 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
173 Ok(self.derive_safe_address(account.address()))
174 }
175
176 fn derive_proxy_wallet(&self, owner: Address) -> Result<Address, RelayError> {
177 let proxy_factory = self.contract_config.proxy_factory.ok_or_else(|| {
178 RelayError::Api("Proxy wallet not supported on this chain".to_string())
179 })?;
180
181 let salt = keccak256(owner.as_slice());
184
185 let init_code_hash = hex::decode(PROXY_INIT_CODE_HASH).unwrap();
186
187 let mut input = Vec::new();
189 input.push(0xff);
190 input.extend_from_slice(proxy_factory.as_slice());
191 input.extend_from_slice(salt.as_slice());
192 input.extend_from_slice(&init_code_hash);
193
194 let hash = keccak256(input);
195 Ok(Address::from_slice(&hash[12..]))
196 }
197
198 pub fn get_expected_proxy_wallet(&self) -> Result<Address, RelayError> {
199 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
200 self.derive_proxy_wallet(account.address())
201 }
202
203 pub async fn get_relay_payload(&self, address: Address) -> Result<(Address, u64), RelayError> {
205 #[derive(serde::Deserialize)]
206 struct RelayPayload {
207 address: String,
208 #[serde(deserialize_with = "crate::types::deserialize_nonce")]
209 nonce: u64,
210 }
211
212 let url = self
213 .base_url
214 .join(&format!("relay-payload?address={}&type=PROXY", address))?;
215 let resp = self.client.get(url).send().await?;
216
217 if !resp.status().is_success() {
218 let text = resp.text().await?;
219 return Err(RelayError::Api(format!(
220 "get_relay_payload failed: {}",
221 text
222 )));
223 }
224
225 let data = resp.json::<RelayPayload>().await?;
226 let relay_address: Address = data
227 .address
228 .parse()
229 .map_err(|e| RelayError::Api(format!("Invalid relay address: {}", e)))?;
230 Ok((relay_address, data.nonce))
231 }
232
233 #[allow(clippy::too_many_arguments)]
235 fn create_proxy_struct_hash(
236 &self,
237 from: Address,
238 to: Address,
239 data: &[u8],
240 tx_fee: U256,
241 gas_price: U256,
242 gas_limit: U256,
243 nonce: u64,
244 relay_hub: Address,
245 relay: Address,
246 ) -> [u8; 32] {
247 let mut message = Vec::new();
248
249 message.extend_from_slice(b"rlx:");
251 message.extend_from_slice(from.as_slice());
253 message.extend_from_slice(to.as_slice());
255 message.extend_from_slice(data);
257 message.extend_from_slice(&tx_fee.to_be_bytes::<32>());
259 message.extend_from_slice(&gas_price.to_be_bytes::<32>());
261 message.extend_from_slice(&gas_limit.to_be_bytes::<32>());
263 message.extend_from_slice(&U256::from(nonce).to_be_bytes::<32>());
265 message.extend_from_slice(relay_hub.as_slice());
267 message.extend_from_slice(relay.as_slice());
269
270 keccak256(&message).into()
271 }
272
273 fn encode_proxy_transaction_data(&self, txns: &[SafeTransaction]) -> Vec<u8> {
275 alloy::sol! {
279 struct ProxyTransaction {
280 uint8 typeCode;
281 address to;
282 uint256 value;
283 bytes data;
284 }
285 function proxy(ProxyTransaction[] txns);
286 }
287
288 let proxy_txns: Vec<ProxyTransaction> = txns
289 .iter()
290 .map(|tx| {
291 ProxyTransaction {
292 typeCode: 1, to: tx.to,
294 value: tx.value,
295 data: tx.data.clone(),
296 }
297 })
298 .collect();
299
300 let call = proxyCall { txns: proxy_txns };
302 call.abi_encode()
303 }
304
305 fn create_safe_multisend_transaction(&self, txns: &[SafeTransaction]) -> SafeTransaction {
306 if txns.len() == 1 {
307 return txns[0].clone();
308 }
309
310 let mut encoded_txns = Vec::new();
311 for tx in txns {
312 let mut packed = Vec::new();
314 packed.push(tx.operation);
315 packed.extend_from_slice(tx.to.as_slice());
316 packed.extend_from_slice(&tx.value.to_be_bytes::<32>());
317 packed.extend_from_slice(&U256::from(tx.data.len()).to_be_bytes::<32>());
318 packed.extend_from_slice(&tx.data);
319 encoded_txns.extend_from_slice(&packed);
320 }
321
322 let mut data = hex::decode("8d80ff0a").unwrap();
325
326 let multisend_data = (Bytes::from(encoded_txns),).abi_encode();
328 data.extend_from_slice(&multisend_data);
329
330 SafeTransaction {
331 to: self.contract_config.safe_multisend,
332 operation: 1, data: data.into(),
334 value: U256::ZERO,
335 }
336 }
337
338 fn split_and_pack_sig_safe(&self, sig: alloy::primitives::Signature) -> String {
339 let v_raw = if sig.v() { 1u8 } else { 0u8 };
342 let v = v_raw + 31;
343
344 let mut packed = Vec::new();
346 packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
347 packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
348 packed.push(v);
349
350 format!("0x{}", hex::encode(packed))
351 }
352
353 fn split_and_pack_sig_proxy(&self, sig: alloy::primitives::Signature) -> String {
354 let v = if sig.v() { 28u8 } else { 27u8 };
356
357 let mut packed = Vec::new();
359 packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
360 packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
361 packed.push(v);
362
363 format!("0x{}", hex::encode(packed))
364 }
365
366 pub async fn execute(
367 &self,
368 transactions: Vec<SafeTransaction>,
369 metadata: Option<String>,
370 ) -> Result<RelayerTransactionResponse, RelayError> {
371 self.execute_with_gas(transactions, metadata, None).await
372 }
373
374 pub async fn execute_with_gas(
375 &self,
376 transactions: Vec<SafeTransaction>,
377 metadata: Option<String>,
378 gas_limit: Option<u64>,
379 ) -> Result<RelayerTransactionResponse, RelayError> {
380 match self.wallet_type {
381 WalletType::Safe => self.execute_safe(transactions, metadata).await,
382 WalletType::Proxy => self.execute_proxy(transactions, metadata, gas_limit).await,
383 }
384 }
385
386 async fn execute_safe(
387 &self,
388 transactions: Vec<SafeTransaction>,
389 metadata: Option<String>,
390 ) -> Result<RelayerTransactionResponse, RelayError> {
391 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
392 let from_address = account.address();
393
394 let safe_address = self.derive_safe_address(from_address);
395
396 if !self.get_deployed(safe_address).await? {
397 return Err(RelayError::Api(format!(
398 "Safe {} is not deployed",
399 safe_address
400 )));
401 }
402
403 let nonce = self.get_nonce(from_address).await?;
404
405 let aggregated = self.create_safe_multisend_transaction(&transactions);
406
407 let safe_tx = SafeTx {
408 to: aggregated.to,
409 value: aggregated.value,
410 data: aggregated.data,
411 operation: aggregated.operation,
412 safeTxGas: U256::ZERO,
413 baseGas: U256::ZERO,
414 gasPrice: U256::ZERO,
415 gasToken: Address::ZERO,
416 refundReceiver: Address::ZERO,
417 nonce: U256::from(nonce),
418 };
419
420 let domain = Eip712Domain {
421 name: None,
422 version: None,
423 chain_id: Some(U256::from(self.chain_id)),
424 verifying_contract: Some(safe_address),
425 salt: None,
426 };
427
428 let struct_hash = safe_tx.eip712_signing_hash(&domain);
429 let signature = account
430 .signer()
431 .sign_message(struct_hash.as_slice())
432 .await
433 .map_err(|e| RelayError::Signer(e.to_string()))?;
434 let packed_sig = self.split_and_pack_sig_safe(signature);
435
436 #[derive(Serialize)]
437 struct SigParams {
438 #[serde(rename = "gasPrice")]
439 gas_price: String,
440 operation: String,
441 #[serde(rename = "safeTxnGas")]
442 safe_tx_gas: String,
443 #[serde(rename = "baseGas")]
444 base_gas: String,
445 #[serde(rename = "gasToken")]
446 gas_token: String,
447 #[serde(rename = "refundReceiver")]
448 refund_receiver: String,
449 }
450
451 #[derive(Serialize)]
452 struct Body {
453 #[serde(rename = "type")]
454 type_: String,
455 from: String,
456 to: String,
457 #[serde(rename = "proxyWallet")]
458 proxy_wallet: String,
459 data: String,
460 signature: String,
461 #[serde(rename = "signatureParams")]
462 signature_params: SigParams,
463 #[serde(rename = "value")]
464 value: String,
465 nonce: String,
466 #[serde(skip_serializing_if = "Option::is_none")]
467 metadata: Option<String>,
468 }
469
470 let body = Body {
471 type_: "SAFE".to_string(),
472 from: from_address.to_string(),
473 to: safe_tx.to.to_string(),
474 proxy_wallet: safe_address.to_string(),
475 data: safe_tx.data.to_string(),
476 signature: packed_sig,
477 signature_params: SigParams {
478 gas_price: "0".to_string(),
479 operation: safe_tx.operation.to_string(),
480 safe_tx_gas: "0".to_string(),
481 base_gas: "0".to_string(),
482 gas_token: Address::ZERO.to_string(),
483 refund_receiver: Address::ZERO.to_string(),
484 },
485 value: safe_tx.value.to_string(),
486 nonce: nonce.to_string(),
487 metadata,
488 };
489
490 self._post_request("submit", &body).await
491 }
492
493 async fn execute_proxy(
494 &self,
495 transactions: Vec<SafeTransaction>,
496 metadata: Option<String>,
497 gas_limit: Option<u64>,
498 ) -> Result<RelayerTransactionResponse, RelayError> {
499 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
500 let from_address = account.address();
501
502 let proxy_wallet = self.derive_proxy_wallet(from_address)?;
503 let relay_hub = self
504 .contract_config
505 .relay_hub
506 .ok_or_else(|| RelayError::Api("Relay hub not configured".to_string()))?;
507 let proxy_factory = self
508 .contract_config
509 .proxy_factory
510 .ok_or_else(|| RelayError::Api("Proxy factory not configured".to_string()))?;
511
512 let (relay_address, nonce) = self.get_relay_payload(from_address).await?;
514
515 let encoded_data = self.encode_proxy_transaction_data(&transactions);
517
518 let tx_fee = U256::ZERO;
520 let gas_price = U256::ZERO;
521 let gas_limit = U256::from(gas_limit.unwrap_or(10_000_000u64));
522
523 let struct_hash = self.create_proxy_struct_hash(
529 from_address,
530 proxy_factory, &encoded_data,
532 tx_fee,
533 gas_price,
534 gas_limit,
535 nonce,
536 relay_hub,
537 relay_address,
538 );
539
540 let signature = account
542 .signer()
543 .sign_message(&struct_hash)
544 .await
545 .map_err(|e| RelayError::Signer(e.to_string()))?;
546 let packed_sig = self.split_and_pack_sig_proxy(signature);
547
548 #[derive(Serialize)]
549 struct SigParams {
550 #[serde(rename = "relayerFee")]
551 relayer_fee: String,
552 #[serde(rename = "gasLimit")]
553 gas_limit: String,
554 #[serde(rename = "gasPrice")]
555 gas_price: String,
556 #[serde(rename = "relayHub")]
557 relay_hub: String,
558 relay: String,
559 }
560
561 #[derive(Serialize)]
562 struct Body {
563 #[serde(rename = "type")]
564 type_: String,
565 from: String,
566 to: String,
567 #[serde(rename = "proxyWallet")]
568 proxy_wallet: String,
569 data: String,
570 signature: String,
571 #[serde(rename = "signatureParams")]
572 signature_params: SigParams,
573 nonce: String,
574 #[serde(skip_serializing_if = "Option::is_none")]
575 metadata: Option<String>,
576 }
577
578 let body = Body {
579 type_: "PROXY".to_string(),
580 from: from_address.to_string(),
581 to: proxy_factory.to_string(),
582 proxy_wallet: proxy_wallet.to_string(),
583 data: format!("0x{}", hex::encode(&encoded_data)),
584 signature: packed_sig,
585 signature_params: SigParams {
586 relayer_fee: "0".to_string(),
587 gas_limit: gas_limit.to_string(),
588 gas_price: "0".to_string(),
589 relay_hub: relay_hub.to_string(),
590 relay: relay_address.to_string(),
591 },
592 nonce: nonce.to_string(),
593 metadata,
594 };
595
596 self._post_request("submit", &body).await
597 }
598
599 pub async fn estimate_redemption_gas(
637 &self,
638 condition_id: [u8; 32],
639 index_sets: Vec<U256>,
640 ) -> Result<u64, RelayError> {
641 alloy::sol! {
643 function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
644 }
645
646 let collateral =
648 Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None)
649 .map_err(|e| RelayError::Api(format!("Invalid collateral address: {}", e)))?;
650 let ctf_exchange =
651 Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None)
652 .map_err(|e| RelayError::Api(format!("Invalid CTF exchange address: {}", e)))?;
653 let parent_collection_id = [0u8; 32];
654
655 let call = redeemPositionsCall {
657 collateral,
658 parentCollectionId: parent_collection_id.into(),
659 conditionId: condition_id.into(),
660 indexSets: index_sets,
661 };
662 let redemption_calldata = Bytes::from(call.abi_encode());
663
664 let proxy_wallet = match self.wallet_type {
666 WalletType::Proxy => self.get_expected_proxy_wallet()?,
667 WalletType::Safe => self.get_expected_safe()?,
668 };
669
670 let provider = ProviderBuilder::new().connect_http(
672 self.contract_config
673 .rpc_url
674 .parse()
675 .map_err(|e| RelayError::Api(format!("Invalid RPC URL: {}", e)))?,
676 );
677
678 let tx = TransactionRequest::default()
680 .with_from(proxy_wallet)
681 .with_to(ctf_exchange)
682 .with_input(redemption_calldata);
683
684 let inner_gas_used = provider
686 .estimate_gas(tx)
687 .await
688 .map_err(|e| RelayError::Api(format!("Gas estimation failed: {}", e)))?;
689
690 let relayer_overhead: u64 = 50_000;
692 let safe_gas_limit = (inner_gas_used + relayer_overhead) * 120 / 100;
693
694 Ok(safe_gas_limit)
695 }
696
697 pub async fn submit_gasless_redemption(
698 &self,
699 condition_id: [u8; 32],
700 index_sets: Vec<alloy::primitives::U256>,
701 ) -> Result<RelayerTransactionResponse, RelayError> {
702 self.submit_gasless_redemption_with_gas_estimation(condition_id, index_sets, false)
703 .await
704 }
705
706 pub async fn submit_gasless_redemption_with_gas_estimation(
707 &self,
708 condition_id: [u8; 32],
709 index_sets: Vec<alloy::primitives::U256>,
710 estimate_gas: bool,
711 ) -> Result<RelayerTransactionResponse, RelayError> {
712 alloy::sol! {
714 function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
715 }
716
717 let collateral =
720 Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None).unwrap();
721 let ctf_exchange =
723 Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None).unwrap();
724 let parent_collection_id = [0u8; 32];
725
726 let call = redeemPositionsCall {
728 collateral,
729 parentCollectionId: parent_collection_id.into(),
730 conditionId: condition_id.into(),
731 indexSets: index_sets.clone(),
732 };
733 let data = call.abi_encode();
734
735 let gas_limit = if estimate_gas {
737 Some(
738 self.estimate_redemption_gas(condition_id, index_sets.clone())
739 .await?,
740 )
741 } else {
742 None
743 };
744
745 let tx = SafeTransaction {
747 to: ctf_exchange,
748 value: U256::ZERO,
749 data: data.into(),
750 operation: 0, };
752
753 self.execute_with_gas(vec![tx], None, gas_limit).await
756 }
757
758 async fn _post_request<T: Serialize>(
759 &self,
760 endpoint: &str,
761 body: &T,
762 ) -> Result<RelayerTransactionResponse, RelayError> {
763 let url = self.base_url.join(endpoint)?;
764 let body_str = serde_json::to_string(body)?;
765
766 eprintln!("DEBUG POST {} path={}", url, url.path());
767 eprintln!("DEBUG body: {}", body_str);
768 tracing::debug!("POST {} with body: {}", url, body_str);
769
770 let mut headers = if let Some(account) = &self.account {
771 if let Some(config) = account.config() {
772 config
773 .generate_relayer_v2_headers("POST", url.path(), Some(&body_str))
774 .map_err(RelayError::Api)?
775 } else {
776 return Err(RelayError::Api(
777 "Builder config missing - cannot authenticate request".to_string(),
778 ));
779 }
780 } else {
781 return Err(RelayError::Api(
782 "Account missing - cannot authenticate request".to_string(),
783 ));
784 };
785
786 headers.insert(
787 reqwest::header::CONTENT_TYPE,
788 reqwest::header::HeaderValue::from_static("application/json"),
789 );
790
791 let resp = self
792 .client
793 .post(url.clone())
794 .headers(headers)
795 .body(body_str.clone())
796 .send()
797 .await?;
798
799 let status = resp.status();
800 tracing::debug!("Response status for {}: {}", endpoint, status);
801
802 if !status.is_success() {
803 let text = resp.text().await?;
804 tracing::error!(
805 "Request to {} failed with status {}: {}",
806 endpoint,
807 status,
808 text
809 );
810 return Err(RelayError::Api(format!("Request failed: {}", text)));
811 }
812
813 let response_text = resp.text().await?;
815 tracing::debug!("Raw response body from {}: {}", endpoint, response_text);
816
817 serde_json::from_str(&response_text).map_err(|e| {
819 tracing::error!(
820 "Failed to decode response from {}: {}. Raw body: {}",
821 endpoint,
822 e,
823 response_text
824 );
825 RelayError::SerdeJson(e)
826 })
827 }
828}
829
830pub struct RelayClientBuilder {
831 base_url: String,
832 chain_id: u64,
833 account: Option<BuilderAccount>,
834 wallet_type: WalletType,
835}
836
837impl RelayClientBuilder {
838 pub fn new(base_url: &str, chain_id: u64) -> Result<Self, RelayError> {
839 let mut base_url = Url::parse(base_url)?;
840 if !base_url.path().ends_with('/') {
841 base_url.set_path(&format!("{}/", base_url.path()));
842 }
843
844 Ok(Self {
845 base_url: base_url.to_string(),
846 chain_id,
847 account: None,
848 wallet_type: WalletType::default(),
849 })
850 }
851
852 pub fn with_account(mut self, account: BuilderAccount) -> Self {
853 self.account = Some(account);
854 self
855 }
856
857 pub fn wallet_type(mut self, wallet_type: WalletType) -> Self {
858 self.wallet_type = wallet_type;
859 self
860 }
861
862 pub fn build(self) -> Result<RelayClient, RelayError> {
863 let mut base_url = Url::parse(&self.base_url)?;
864 if !base_url.path().ends_with('/') {
865 base_url.set_path(&format!("{}/", base_url.path()));
866 }
867
868 let contract_config = get_contract_config(self.chain_id)
869 .ok_or_else(|| RelayError::Api(format!("Unsupported chain ID: {}", self.chain_id)))?;
870
871 Ok(RelayClient {
872 client: Client::new(),
873 base_url,
874 chain_id: self.chain_id,
875 account: self.account,
876 contract_config,
877 wallet_type: self.wallet_type,
878 })
879 }
880}