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 polyoxide_core::{retry_after_header, HttpClient, HttpClientBuilder, RateLimiter, RetryConfig};
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 "d21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b";
27
28const CALL_OPERATION: u8 = 0;
30const DELEGATE_CALL_OPERATION: u8 = 1;
31
32const PROXY_CALL_TYPE_CODE: u8 = 1;
34
35const MULTISEND_SELECTOR: [u8; 4] = [0x8d, 0x80, 0xff, 0x0a];
37
38#[derive(Serialize)]
41struct SafeSigParams {
42 #[serde(rename = "gasPrice")]
43 gas_price: String,
44 operation: String,
45 #[serde(rename = "safeTxnGas")]
46 safe_tx_gas: String,
47 #[serde(rename = "baseGas")]
48 base_gas: String,
49 #[serde(rename = "gasToken")]
50 gas_token: String,
51 #[serde(rename = "refundReceiver")]
52 refund_receiver: String,
53}
54
55#[derive(Serialize)]
56struct SafeSubmitBody {
57 #[serde(rename = "type")]
58 type_: String,
59 from: String,
60 to: String,
61 #[serde(rename = "proxyWallet")]
62 proxy_wallet: String,
63 data: String,
64 signature: String,
65 #[serde(rename = "signatureParams")]
66 signature_params: SafeSigParams,
67 value: String,
68 nonce: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 metadata: Option<String>,
71}
72
73#[derive(Serialize)]
74struct ProxySigParams {
75 #[serde(rename = "relayerFee")]
76 relayer_fee: String,
77 #[serde(rename = "gasLimit")]
78 gas_limit: String,
79 #[serde(rename = "gasPrice")]
80 gas_price: String,
81 #[serde(rename = "relayHub")]
82 relay_hub: String,
83 relay: String,
84}
85
86#[derive(Serialize)]
87struct ProxySubmitBody {
88 #[serde(rename = "type")]
89 type_: String,
90 from: String,
91 to: String,
92 #[serde(rename = "proxyWallet")]
93 proxy_wallet: String,
94 data: String,
95 signature: String,
96 #[serde(rename = "signatureParams")]
97 signature_params: ProxySigParams,
98 nonce: String,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 metadata: Option<String>,
101}
102
103#[derive(Debug, Clone)]
108pub struct RelayClient {
109 http_client: HttpClient,
110 chain_id: u64,
111 account: Option<BuilderAccount>,
112 contract_config: ContractConfig,
113 wallet_type: WalletType,
114}
115
116impl RelayClient {
117 pub fn new(
119 private_key: impl Into<String>,
120 config: Option<BuilderConfig>,
121 ) -> Result<Self, RelayError> {
122 let account = BuilderAccount::new(private_key, config)?;
123 Self::builder()?.with_account(account).build()
124 }
125
126 pub fn builder() -> Result<RelayClientBuilder, RelayError> {
128 RelayClientBuilder::new()
129 }
130
131 pub fn default_builder() -> Result<RelayClientBuilder, RelayError> {
133 Ok(RelayClientBuilder::default())
134 }
135
136 pub fn from_account(account: BuilderAccount) -> Result<Self, RelayError> {
138 Self::builder()?.with_account(account).build()
139 }
140
141 pub fn address(&self) -> Option<Address> {
143 self.account.as_ref().map(|a| a.address())
144 }
145
146 async fn get_with_retry(&self, path: &str, url: &Url) -> Result<reqwest::Response, RelayError> {
151 let mut attempt = 0u32;
152 loop {
153 self.http_client.acquire_rate_limit(path, None).await;
154 let resp = self.http_client.client.get(url.clone()).send().await?;
155 let retry_after = retry_after_header(&resp);
156
157 if let Some(backoff) =
158 self.http_client
159 .should_retry(resp.status(), attempt, retry_after.as_deref())
160 {
161 attempt += 1;
162 tracing::warn!(
163 "Rate limited (429) on {}, retry {} after {}ms",
164 path,
165 attempt,
166 backoff.as_millis()
167 );
168 tokio::time::sleep(backoff).await;
169 continue;
170 }
171
172 if !resp.status().is_success() {
173 let text = resp.text().await?;
174 return Err(RelayError::Api(format!("{} failed: {}", path, text)));
175 }
176
177 return Ok(resp);
178 }
179 }
180
181 pub async fn ping(&self) -> Result<Duration, RelayError> {
198 let url = self.http_client.base_url.clone();
199 let start = Instant::now();
200 let _resp = self.get_with_retry("/", &url).await?;
201 Ok(start.elapsed())
202 }
203
204 pub async fn get_nonce(&self, address: Address) -> Result<u64, RelayError> {
206 let url = self.http_client.base_url.join(&format!(
207 "nonce?address={}&type={}",
208 address,
209 self.wallet_type.as_str()
210 ))?;
211 let resp = self.get_with_retry("/nonce", &url).await?;
212 let data = resp.json::<NonceResponse>().await?;
213 Ok(data.nonce)
214 }
215
216 pub async fn get_transaction(
218 &self,
219 transaction_id: &str,
220 ) -> Result<TransactionStatusResponse, RelayError> {
221 let url = self
222 .http_client
223 .base_url
224 .join(&format!("transaction?id={}", transaction_id))?;
225 let resp = self.get_with_retry("/transaction", &url).await?;
226 resp.json::<TransactionStatusResponse>()
227 .await
228 .map_err(Into::into)
229 }
230
231 pub async fn get_deployed(&self, safe_address: Address) -> Result<bool, RelayError> {
233 #[derive(serde::Deserialize)]
234 struct DeployedResponse {
235 deployed: bool,
236 }
237 let url = self
238 .http_client
239 .base_url
240 .join(&format!("deployed?address={}", safe_address))?;
241 let resp = self.get_with_retry("/deployed", &url).await?;
242 let data = resp.json::<DeployedResponse>().await?;
243 Ok(data.deployed)
244 }
245
246 fn derive_safe_address(&self, owner: Address) -> Address {
247 let salt = keccak256(owner.abi_encode());
248 let init_code_hash = hex::decode(SAFE_INIT_CODE_HASH).expect("valid hex constant");
249
250 let mut input = Vec::new();
252 input.push(0xff);
253 input.extend_from_slice(self.contract_config.safe_factory.as_slice());
254 input.extend_from_slice(salt.as_slice());
255 input.extend_from_slice(&init_code_hash);
256
257 let hash = keccak256(input);
258 Address::from_slice(&hash[12..])
259 }
260
261 pub fn get_expected_safe(&self) -> Result<Address, RelayError> {
263 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
264 Ok(self.derive_safe_address(account.address()))
265 }
266
267 fn derive_proxy_wallet(&self, owner: Address) -> Result<Address, RelayError> {
268 let proxy_factory = self.contract_config.proxy_factory.ok_or_else(|| {
269 RelayError::Api("Proxy wallet not supported on this chain".to_string())
270 })?;
271
272 let salt = keccak256(owner.as_slice());
275
276 let init_code_hash = hex::decode(PROXY_INIT_CODE_HASH).expect("valid hex constant");
277
278 let mut input = Vec::new();
280 input.push(0xff);
281 input.extend_from_slice(proxy_factory.as_slice());
282 input.extend_from_slice(salt.as_slice());
283 input.extend_from_slice(&init_code_hash);
284
285 let hash = keccak256(input);
286 Ok(Address::from_slice(&hash[12..]))
287 }
288
289 pub fn get_expected_proxy_wallet(&self) -> Result<Address, RelayError> {
291 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
292 self.derive_proxy_wallet(account.address())
293 }
294
295 pub async fn get_relay_payload(&self, address: Address) -> Result<(Address, u64), RelayError> {
297 #[derive(serde::Deserialize)]
298 struct RelayPayload {
299 address: String,
300 #[serde(deserialize_with = "crate::types::deserialize_nonce")]
301 nonce: u64,
302 }
303
304 let url = self
305 .http_client
306 .base_url
307 .join(&format!("relay-payload?address={}&type=PROXY", address))?;
308 let resp = self.get_with_retry("/relay-payload", &url).await?;
309 let data = resp.json::<RelayPayload>().await?;
310 let relay_address: Address = data
311 .address
312 .parse()
313 .map_err(|e| RelayError::Api(format!("Invalid relay address: {}", e)))?;
314 Ok((relay_address, data.nonce))
315 }
316
317 #[allow(clippy::too_many_arguments)]
319 fn create_proxy_struct_hash(
320 &self,
321 from: Address,
322 to: Address,
323 data: &[u8],
324 tx_fee: U256,
325 gas_price: U256,
326 gas_limit: U256,
327 nonce: u64,
328 relay_hub: Address,
329 relay: Address,
330 ) -> [u8; 32] {
331 let mut message = Vec::new();
332
333 message.extend_from_slice(b"rlx:");
335 message.extend_from_slice(from.as_slice());
337 message.extend_from_slice(to.as_slice());
339 message.extend_from_slice(data);
341 message.extend_from_slice(&tx_fee.to_be_bytes::<32>());
343 message.extend_from_slice(&gas_price.to_be_bytes::<32>());
345 message.extend_from_slice(&gas_limit.to_be_bytes::<32>());
347 message.extend_from_slice(&U256::from(nonce).to_be_bytes::<32>());
349 message.extend_from_slice(relay_hub.as_slice());
351 message.extend_from_slice(relay.as_slice());
353
354 keccak256(&message).into()
355 }
356
357 fn encode_proxy_transaction_data(&self, txns: &[SafeTransaction]) -> Vec<u8> {
359 alloy::sol! {
363 struct ProxyTransaction {
364 uint8 typeCode;
365 address to;
366 uint256 value;
367 bytes data;
368 }
369 function proxy(ProxyTransaction[] txns);
370 }
371
372 let proxy_txns: Vec<ProxyTransaction> = txns
373 .iter()
374 .map(|tx| ProxyTransaction {
375 typeCode: PROXY_CALL_TYPE_CODE,
376 to: tx.to,
377 value: tx.value,
378 data: tx.data.clone(),
379 })
380 .collect();
381
382 let call = proxyCall { txns: proxy_txns };
384 call.abi_encode()
385 }
386
387 fn create_safe_multisend_transaction(&self, txns: &[SafeTransaction]) -> SafeTransaction {
388 if txns.len() == 1 {
389 return txns[0].clone();
390 }
391
392 let mut encoded_txns = Vec::new();
393 for tx in txns {
394 let mut packed = Vec::new();
396 packed.push(tx.operation);
397 packed.extend_from_slice(tx.to.as_slice());
398 packed.extend_from_slice(&tx.value.to_be_bytes::<32>());
399 packed.extend_from_slice(&U256::from(tx.data.len()).to_be_bytes::<32>());
400 packed.extend_from_slice(&tx.data);
401 encoded_txns.extend_from_slice(&packed);
402 }
403
404 let mut data = MULTISEND_SELECTOR.to_vec();
405
406 let multisend_data = (Bytes::from(encoded_txns),).abi_encode();
408 data.extend_from_slice(&multisend_data);
409
410 SafeTransaction {
411 to: self.contract_config.safe_multisend,
412 operation: DELEGATE_CALL_OPERATION,
413 data: data.into(),
414 value: U256::ZERO,
415 }
416 }
417
418 fn split_and_pack_sig_safe(&self, sig: alloy::primitives::Signature) -> String {
419 let v_raw = if sig.v() { 1u8 } else { 0u8 };
422 let v = v_raw + 31;
423
424 let mut packed = Vec::new();
426 packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
427 packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
428 packed.push(v);
429
430 format!("0x{}", hex::encode(packed))
431 }
432
433 fn split_and_pack_sig_proxy(&self, sig: alloy::primitives::Signature) -> String {
434 let v = if sig.v() { 28u8 } else { 27u8 };
436
437 let mut packed = Vec::new();
439 packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
440 packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
441 packed.push(v);
442
443 format!("0x{}", hex::encode(packed))
444 }
445
446 pub async fn execute(
448 &self,
449 transactions: Vec<SafeTransaction>,
450 metadata: Option<String>,
451 ) -> Result<RelayerTransactionResponse, RelayError> {
452 self.execute_with_gas(transactions, metadata, None).await
453 }
454
455 pub async fn execute_with_gas(
460 &self,
461 transactions: Vec<SafeTransaction>,
462 metadata: Option<String>,
463 gas_limit: Option<u64>,
464 ) -> Result<RelayerTransactionResponse, RelayError> {
465 if transactions.is_empty() {
466 return Err(RelayError::Api("No transactions to execute".into()));
467 }
468 match self.wallet_type {
469 WalletType::Safe => self.execute_safe(transactions, metadata).await,
470 WalletType::Proxy => self.execute_proxy(transactions, metadata, gas_limit).await,
471 }
472 }
473
474 async fn execute_safe(
475 &self,
476 transactions: Vec<SafeTransaction>,
477 metadata: Option<String>,
478 ) -> Result<RelayerTransactionResponse, RelayError> {
479 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
480 let from_address = account.address();
481
482 let safe_address = self.derive_safe_address(from_address);
483
484 if !self.get_deployed(safe_address).await? {
485 return Err(RelayError::Api(format!(
486 "Safe {} is not deployed",
487 safe_address
488 )));
489 }
490
491 let nonce = self.get_nonce(from_address).await?;
492
493 let aggregated = self.create_safe_multisend_transaction(&transactions);
494
495 let safe_tx = SafeTx {
496 to: aggregated.to,
497 value: aggregated.value,
498 data: aggregated.data,
499 operation: aggregated.operation,
500 safeTxGas: U256::ZERO,
501 baseGas: U256::ZERO,
502 gasPrice: U256::ZERO,
503 gasToken: Address::ZERO,
504 refundReceiver: Address::ZERO,
505 nonce: U256::from(nonce),
506 };
507
508 let domain = Eip712Domain {
509 name: None,
510 version: None,
511 chain_id: Some(U256::from(self.chain_id)),
512 verifying_contract: Some(safe_address),
513 salt: None,
514 };
515
516 let struct_hash = safe_tx.eip712_signing_hash(&domain);
517 let signature = account
518 .signer()
519 .sign_message(struct_hash.as_slice())
520 .await
521 .map_err(|e| RelayError::Signer(e.to_string()))?;
522 let packed_sig = self.split_and_pack_sig_safe(signature);
523
524 let body = SafeSubmitBody {
525 type_: "SAFE".to_string(),
526 from: from_address.to_string(),
527 to: safe_tx.to.to_string(),
528 proxy_wallet: safe_address.to_string(),
529 data: safe_tx.data.to_string(),
530 signature: packed_sig,
531 signature_params: SafeSigParams {
532 gas_price: "0".to_string(),
533 operation: safe_tx.operation.to_string(),
534 safe_tx_gas: "0".to_string(),
535 base_gas: "0".to_string(),
536 gas_token: Address::ZERO.to_string(),
537 refund_receiver: Address::ZERO.to_string(),
538 },
539 value: safe_tx.value.to_string(),
540 nonce: nonce.to_string(),
541 metadata,
542 };
543
544 self._post_request("submit", &body).await
545 }
546
547 async fn execute_proxy(
548 &self,
549 transactions: Vec<SafeTransaction>,
550 metadata: Option<String>,
551 gas_limit: Option<u64>,
552 ) -> Result<RelayerTransactionResponse, RelayError> {
553 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
554 let from_address = account.address();
555
556 let proxy_wallet = self.derive_proxy_wallet(from_address)?;
557 let relay_hub = self
558 .contract_config
559 .relay_hub
560 .ok_or_else(|| RelayError::Api("Relay hub not configured".to_string()))?;
561 let proxy_factory = self
562 .contract_config
563 .proxy_factory
564 .ok_or_else(|| RelayError::Api("Proxy factory not configured".to_string()))?;
565
566 let (relay_address, nonce) = self.get_relay_payload(from_address).await?;
568
569 let encoded_data = self.encode_proxy_transaction_data(&transactions);
571
572 let tx_fee = U256::ZERO;
574 let gas_price = U256::ZERO;
575 let gas_limit = U256::from(gas_limit.unwrap_or(10_000_000u64));
576
577 let struct_hash = self.create_proxy_struct_hash(
579 from_address,
580 proxy_factory,
581 &encoded_data,
582 tx_fee,
583 gas_price,
584 gas_limit,
585 nonce,
586 relay_hub,
587 relay_address,
588 );
589
590 let signature = account
592 .signer()
593 .sign_message(&struct_hash)
594 .await
595 .map_err(|e| RelayError::Signer(e.to_string()))?;
596 let packed_sig = self.split_and_pack_sig_proxy(signature);
597
598 let body = ProxySubmitBody {
599 type_: "PROXY".to_string(),
600 from: from_address.to_string(),
601 to: proxy_factory.to_string(),
602 proxy_wallet: proxy_wallet.to_string(),
603 data: format!("0x{}", hex::encode(&encoded_data)),
604 signature: packed_sig,
605 signature_params: ProxySigParams {
606 relayer_fee: "0".to_string(),
607 gas_limit: gas_limit.to_string(),
608 gas_price: "0".to_string(),
609 relay_hub: relay_hub.to_string(),
610 relay: relay_address.to_string(),
611 },
612 nonce: nonce.to_string(),
613 metadata,
614 };
615
616 self._post_request("submit", &body).await
617 }
618
619 pub async fn estimate_redemption_gas(
657 &self,
658 condition_id: [u8; 32],
659 index_sets: Vec<U256>,
660 ) -> Result<u64, RelayError> {
661 alloy::sol! {
663 function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
664 }
665
666 let collateral =
668 Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None)
669 .map_err(|e| RelayError::Api(format!("Invalid collateral address: {}", e)))?;
670 let ctf_exchange =
671 Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None)
672 .map_err(|e| RelayError::Api(format!("Invalid CTF exchange address: {}", e)))?;
673 let parent_collection_id = [0u8; 32];
674
675 let call = redeemPositionsCall {
677 collateral,
678 parentCollectionId: parent_collection_id.into(),
679 conditionId: condition_id.into(),
680 indexSets: index_sets,
681 };
682 let redemption_calldata = Bytes::from(call.abi_encode());
683
684 let proxy_wallet = match self.wallet_type {
686 WalletType::Proxy => self.get_expected_proxy_wallet()?,
687 WalletType::Safe => self.get_expected_safe()?,
688 };
689
690 let provider = ProviderBuilder::new().connect_http(
692 self.contract_config
693 .rpc_url
694 .parse()
695 .map_err(|e| RelayError::Api(format!("Invalid RPC URL: {}", e)))?,
696 );
697
698 let tx = TransactionRequest::default()
700 .with_from(proxy_wallet)
701 .with_to(ctf_exchange)
702 .with_input(redemption_calldata);
703
704 let inner_gas_used = provider
706 .estimate_gas(tx)
707 .await
708 .map_err(|e| RelayError::Api(format!("Gas estimation failed: {}", e)))?;
709
710 let relayer_overhead: u64 = 50_000;
712 let safe_gas_limit = (inner_gas_used + relayer_overhead) * 120 / 100;
713
714 Ok(safe_gas_limit)
715 }
716
717 pub async fn submit_gasless_redemption(
719 &self,
720 condition_id: [u8; 32],
721 index_sets: Vec<alloy::primitives::U256>,
722 ) -> Result<RelayerTransactionResponse, RelayError> {
723 self.submit_gasless_redemption_with_gas_estimation(condition_id, index_sets, false)
724 .await
725 }
726
727 pub async fn submit_gasless_redemption_with_gas_estimation(
732 &self,
733 condition_id: [u8; 32],
734 index_sets: Vec<alloy::primitives::U256>,
735 estimate_gas: bool,
736 ) -> Result<RelayerTransactionResponse, RelayError> {
737 alloy::sol! {
739 function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
740 }
741
742 let collateral =
745 Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None)
746 .map_err(|e| RelayError::Api(format!("Invalid address: {}", e)))?;
747 let ctf_exchange =
749 Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None)
750 .map_err(|e| RelayError::Api(format!("Invalid address: {}", e)))?;
751 let parent_collection_id = [0u8; 32];
752
753 let call = redeemPositionsCall {
755 collateral,
756 parentCollectionId: parent_collection_id.into(),
757 conditionId: condition_id.into(),
758 indexSets: index_sets.clone(),
759 };
760 let data = call.abi_encode();
761
762 let gas_limit = if estimate_gas {
764 Some(
765 self.estimate_redemption_gas(condition_id, index_sets.clone())
766 .await?,
767 )
768 } else {
769 None
770 };
771
772 let tx = SafeTransaction {
774 to: ctf_exchange,
775 value: U256::ZERO,
776 data: data.into(),
777 operation: CALL_OPERATION,
778 };
779
780 self.execute_with_gas(vec![tx], None, gas_limit).await
783 }
784
785 async fn _post_request<T: Serialize>(
786 &self,
787 endpoint: &str,
788 body: &T,
789 ) -> Result<RelayerTransactionResponse, RelayError> {
790 let url = self.http_client.base_url.join(endpoint)?;
791 let body_str = serde_json::to_string(body)?;
792 let path = format!("/{}", endpoint);
793 let mut attempt = 0u32;
794
795 loop {
796 self.http_client
797 .acquire_rate_limit(&path, Some(&reqwest::Method::POST))
798 .await;
799
800 let mut headers = if let Some(account) = &self.account {
802 if let Some(config) = account.config() {
803 config
804 .generate_relayer_v2_headers("POST", url.path(), Some(&body_str))
805 .map_err(RelayError::Api)?
806 } else {
807 return Err(RelayError::Api(
808 "Builder config missing - cannot authenticate request".to_string(),
809 ));
810 }
811 } else {
812 return Err(RelayError::Api(
813 "Account missing - cannot authenticate request".to_string(),
814 ));
815 };
816
817 headers.insert(
818 reqwest::header::CONTENT_TYPE,
819 reqwest::header::HeaderValue::from_static("application/json"),
820 );
821
822 let resp = self
823 .http_client
824 .client
825 .post(url.clone())
826 .headers(headers)
827 .body(body_str.clone())
828 .send()
829 .await?;
830
831 let status = resp.status();
832 let retry_after = retry_after_header(&resp);
833 tracing::debug!("Response status for {}: {}", endpoint, status);
834
835 if let Some(backoff) =
836 self.http_client
837 .should_retry(status, attempt, retry_after.as_deref())
838 {
839 attempt += 1;
840 tracing::warn!(
841 "Rate limited (429) on {}, retry {} after {}ms",
842 endpoint,
843 attempt,
844 backoff.as_millis()
845 );
846 tokio::time::sleep(backoff).await;
847 continue;
848 }
849
850 if !status.is_success() {
851 let text = resp.text().await?;
852 tracing::error!(
853 "Request to {} failed with status {}: {}",
854 endpoint,
855 status,
856 polyoxide_core::truncate_for_log(&text)
857 );
858 return Err(RelayError::Api(format!("Request failed: {}", text)));
859 }
860
861 let response_text = resp.text().await?;
862
863 return serde_json::from_str(&response_text).map_err(|e| {
865 tracing::error!(
866 "Failed to decode response from {}: {}. Raw body: {}",
867 endpoint,
868 e,
869 polyoxide_core::truncate_for_log(&response_text)
870 );
871 RelayError::SerdeJson(e)
872 });
873 }
874 }
875}
876
877pub struct RelayClientBuilder {
882 base_url: String,
883 chain_id: u64,
884 account: Option<BuilderAccount>,
885 wallet_type: WalletType,
886 retry_config: Option<RetryConfig>,
887}
888
889impl Default for RelayClientBuilder {
890 fn default() -> Self {
891 let relayer_url = std::env::var("RELAYER_URL")
892 .unwrap_or_else(|_| "https://relayer-v2.polymarket.com/".to_string());
893 let chain_id = std::env::var("CHAIN_ID")
894 .unwrap_or("137".to_string())
895 .parse::<u64>()
896 .unwrap_or(137);
897
898 Self::new()
899 .expect("default URL is valid")
900 .url(&relayer_url)
901 .expect("default URL is valid")
902 .chain_id(chain_id)
903 }
904}
905
906impl RelayClientBuilder {
907 pub fn new() -> Result<Self, RelayError> {
909 let mut base_url = Url::parse("https://relayer-v2.polymarket.com")?;
910 if !base_url.path().ends_with('/') {
911 base_url.set_path(&format!("{}/", base_url.path()));
912 }
913
914 Ok(Self {
915 base_url: base_url.to_string(),
916 chain_id: 137,
917 account: None,
918 wallet_type: WalletType::default(),
919 retry_config: None,
920 })
921 }
922
923 pub fn chain_id(mut self, chain_id: u64) -> Self {
925 self.chain_id = chain_id;
926 self
927 }
928
929 pub fn url(mut self, url: &str) -> Result<Self, RelayError> {
931 let mut base_url = Url::parse(url)?;
932 if !base_url.path().ends_with('/') {
933 base_url.set_path(&format!("{}/", base_url.path()));
934 }
935 self.base_url = base_url.to_string();
936 Ok(self)
937 }
938
939 pub fn with_account(mut self, account: BuilderAccount) -> Self {
941 self.account = Some(account);
942 self
943 }
944
945 pub fn wallet_type(mut self, wallet_type: WalletType) -> Self {
947 self.wallet_type = wallet_type;
948 self
949 }
950
951 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
953 self.retry_config = Some(config);
954 self
955 }
956
957 pub fn build(self) -> Result<RelayClient, RelayError> {
961 let mut base_url = Url::parse(&self.base_url)?;
962 if !base_url.path().ends_with('/') {
963 base_url.set_path(&format!("{}/", base_url.path()));
964 }
965
966 let contract_config = get_contract_config(self.chain_id)
967 .ok_or_else(|| RelayError::Api(format!("Unsupported chain ID: {}", self.chain_id)))?;
968
969 let mut builder = HttpClientBuilder::new(base_url.as_str())
970 .with_rate_limiter(RateLimiter::relay_default());
971 if let Some(config) = self.retry_config {
972 builder = builder.with_retry_config(config);
973 }
974 let http_client = builder.build()?;
975
976 Ok(RelayClient {
977 http_client,
978 chain_id: self.chain_id,
979 account: self.account,
980 contract_config,
981 wallet_type: self.wallet_type,
982 })
983 }
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 #[tokio::test]
991 async fn test_ping() {
992 let client = RelayClient::builder().unwrap().build().unwrap();
993 let result = client.ping().await;
994 assert!(result.is_ok(), "ping failed: {:?}", result.err());
995 }
996
997 #[test]
998 fn test_hex_constants_are_valid() {
999 hex::decode(SAFE_INIT_CODE_HASH).expect("SAFE_INIT_CODE_HASH should be valid hex");
1000 hex::decode(PROXY_INIT_CODE_HASH).expect("PROXY_INIT_CODE_HASH should be valid hex");
1001 }
1002
1003 #[test]
1004 fn test_multisend_selector_matches_expected() {
1005 assert_eq!(MULTISEND_SELECTOR, [0x8d, 0x80, 0xff, 0x0a]);
1007 }
1008
1009 #[test]
1010 fn test_operation_constants() {
1011 assert_eq!(CALL_OPERATION, 0);
1012 assert_eq!(DELEGATE_CALL_OPERATION, 1);
1013 assert_eq!(PROXY_CALL_TYPE_CODE, 1);
1014 }
1015
1016 #[test]
1017 fn test_contract_config_polygon_mainnet() {
1018 let config = get_contract_config(137);
1019 assert!(config.is_some(), "should return config for Polygon mainnet");
1020 let config = config.unwrap();
1021 assert!(config.proxy_factory.is_some());
1022 assert!(config.relay_hub.is_some());
1023 }
1024
1025 #[test]
1026 fn test_contract_config_amoy_testnet() {
1027 let config = get_contract_config(80002);
1028 assert!(config.is_some(), "should return config for Amoy testnet");
1029 let config = config.unwrap();
1030 assert!(
1031 config.proxy_factory.is_none(),
1032 "proxy not supported on Amoy"
1033 );
1034 assert!(
1035 config.relay_hub.is_none(),
1036 "relay hub not supported on Amoy"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_contract_config_unknown_chain() {
1042 assert!(get_contract_config(999).is_none());
1043 }
1044
1045 #[test]
1046 fn test_relay_client_builder_default() {
1047 let builder = RelayClientBuilder::default();
1048 assert_eq!(builder.chain_id, 137);
1049 }
1050
1051 #[test]
1052 fn test_builder_custom_retry_config() {
1053 let config = RetryConfig {
1054 max_retries: 5,
1055 initial_backoff_ms: 1000,
1056 max_backoff_ms: 30_000,
1057 };
1058 let builder = RelayClientBuilder::new().unwrap().with_retry_config(config);
1059 let config = builder.retry_config.unwrap();
1060 assert_eq!(config.max_retries, 5);
1061 assert_eq!(config.initial_backoff_ms, 1000);
1062 }
1063
1064 #[test]
1067 fn test_builder_unsupported_chain() {
1068 let result = RelayClient::builder().unwrap().chain_id(999).build();
1069 assert!(result.is_err());
1070 let err_msg = format!("{}", result.unwrap_err());
1071 assert!(
1072 err_msg.contains("Unsupported chain ID"),
1073 "Expected unsupported chain error, got: {err_msg}"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_builder_with_wallet_type() {
1079 let client = RelayClient::builder()
1080 .unwrap()
1081 .wallet_type(WalletType::Proxy)
1082 .build()
1083 .unwrap();
1084 assert_eq!(client.wallet_type, WalletType::Proxy);
1085 }
1086
1087 #[test]
1088 fn test_builder_no_account_address_is_none() {
1089 let client = RelayClient::builder().unwrap().build().unwrap();
1090 assert!(client.address().is_none());
1091 }
1092
1093 const TEST_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1097
1098 fn test_client_with_account() -> RelayClient {
1099 let account = crate::BuilderAccount::new(TEST_KEY, None).unwrap();
1100 RelayClient::builder()
1101 .unwrap()
1102 .with_account(account)
1103 .build()
1104 .unwrap()
1105 }
1106
1107 #[test]
1108 fn test_derive_safe_address_deterministic() {
1109 let client = test_client_with_account();
1110 let addr1 = client.get_expected_safe().unwrap();
1111 let addr2 = client.get_expected_safe().unwrap();
1112 assert_eq!(addr1, addr2);
1113 }
1114
1115 #[test]
1116 fn test_derive_safe_address_nonzero() {
1117 let client = test_client_with_account();
1118 let addr = client.get_expected_safe().unwrap();
1119 assert_ne!(addr, Address::ZERO);
1120 }
1121
1122 #[test]
1123 fn test_derive_proxy_wallet_deterministic() {
1124 let client = test_client_with_account();
1125 let addr1 = client.get_expected_proxy_wallet().unwrap();
1126 let addr2 = client.get_expected_proxy_wallet().unwrap();
1127 assert_eq!(addr1, addr2);
1128 }
1129
1130 #[test]
1131 fn test_safe_and_proxy_addresses_differ() {
1132 let client = test_client_with_account();
1133 let safe = client.get_expected_safe().unwrap();
1134 let proxy = client.get_expected_proxy_wallet().unwrap();
1135 assert_ne!(safe, proxy);
1136 }
1137
1138 #[test]
1139 fn test_derive_proxy_wallet_no_account() {
1140 let client = RelayClient::builder().unwrap().build().unwrap();
1141 let result = client.get_expected_proxy_wallet();
1142 assert!(result.is_err());
1143 }
1144
1145 #[test]
1146 fn test_derive_proxy_wallet_amoy_unsupported() {
1147 let account = crate::BuilderAccount::new(TEST_KEY, None).unwrap();
1148 let client = RelayClient::builder()
1149 .unwrap()
1150 .chain_id(80002)
1151 .with_account(account)
1152 .build()
1153 .unwrap();
1154 let result = client.get_expected_proxy_wallet();
1156 assert!(result.is_err());
1157 }
1158
1159 #[test]
1162 fn test_split_and_pack_sig_safe_format() {
1163 let client = test_client_with_account();
1164 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1166 alloy::primitives::B256::from([1u8; 32]),
1167 alloy::primitives::B256::from([2u8; 32]),
1168 false, );
1170 let packed = client.split_and_pack_sig_safe(sig);
1171 assert!(packed.starts_with("0x"));
1172 assert_eq!(packed.len(), 132);
1174 assert!(packed.ends_with("1f"), "expected v=31(0x1f), got: {packed}");
1176 }
1177
1178 #[test]
1179 fn test_split_and_pack_sig_safe_v_true() {
1180 let client = test_client_with_account();
1181 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1182 alloy::primitives::B256::from([0xAA; 32]),
1183 alloy::primitives::B256::from([0xBB; 32]),
1184 true, );
1186 let packed = client.split_and_pack_sig_safe(sig);
1187 assert!(packed.ends_with("20"), "expected v=32(0x20), got: {packed}");
1189 }
1190
1191 #[test]
1192 fn test_split_and_pack_sig_proxy_format() {
1193 let client = test_client_with_account();
1194 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1195 alloy::primitives::B256::from([1u8; 32]),
1196 alloy::primitives::B256::from([2u8; 32]),
1197 false, );
1199 let packed = client.split_and_pack_sig_proxy(sig);
1200 assert!(packed.starts_with("0x"));
1201 assert_eq!(packed.len(), 132);
1202 assert!(packed.ends_with("1b"), "expected v=27(0x1b), got: {packed}");
1204 }
1205
1206 #[test]
1207 fn test_split_and_pack_sig_proxy_v_true() {
1208 let client = test_client_with_account();
1209 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1210 alloy::primitives::B256::from([0xAA; 32]),
1211 alloy::primitives::B256::from([0xBB; 32]),
1212 true, );
1214 let packed = client.split_and_pack_sig_proxy(sig);
1215 assert!(packed.ends_with("1c"), "expected v=28(0x1c), got: {packed}");
1217 }
1218
1219 #[test]
1222 fn test_encode_proxy_transaction_data_single() {
1223 let client = test_client_with_account();
1224 let txns = vec![SafeTransaction {
1225 to: Address::ZERO,
1226 operation: 0,
1227 data: alloy::primitives::Bytes::from(vec![0xde, 0xad]),
1228 value: U256::ZERO,
1229 }];
1230 let encoded = client.encode_proxy_transaction_data(&txns);
1231 assert!(
1233 encoded.len() >= 4,
1234 "encoded data too short: {} bytes",
1235 encoded.len()
1236 );
1237 }
1238
1239 #[test]
1240 fn test_encode_proxy_transaction_data_multiple() {
1241 let client = test_client_with_account();
1242 let txns = vec![
1243 SafeTransaction {
1244 to: Address::ZERO,
1245 operation: 0,
1246 data: alloy::primitives::Bytes::from(vec![0x01]),
1247 value: U256::ZERO,
1248 },
1249 SafeTransaction {
1250 to: Address::ZERO,
1251 operation: 0,
1252 data: alloy::primitives::Bytes::from(vec![0x02]),
1253 value: U256::from(100),
1254 },
1255 ];
1256 let encoded = client.encode_proxy_transaction_data(&txns);
1257 assert!(encoded.len() >= 4);
1258 let single = client.encode_proxy_transaction_data(&txns[..1]);
1260 assert!(encoded.len() > single.len());
1261 }
1262
1263 #[test]
1264 fn test_encode_proxy_transaction_data_empty() {
1265 let client = test_client_with_account();
1266 let encoded = client.encode_proxy_transaction_data(&[]);
1267 assert!(encoded.len() >= 4);
1269 }
1270
1271 #[test]
1274 fn test_multisend_single_returns_same() {
1275 let client = test_client_with_account();
1276 let tx = SafeTransaction {
1277 to: Address::from([0x42; 20]),
1278 operation: 0,
1279 data: alloy::primitives::Bytes::from(vec![0xAB]),
1280 value: U256::from(99),
1281 };
1282 let result = client.create_safe_multisend_transaction(std::slice::from_ref(&tx));
1283 assert_eq!(result.to, tx.to);
1284 assert_eq!(result.value, tx.value);
1285 assert_eq!(result.data, tx.data);
1286 assert_eq!(result.operation, tx.operation);
1287 }
1288
1289 #[test]
1290 fn test_multisend_multiple_uses_delegate_call() {
1291 let client = test_client_with_account();
1292 let txns = vec![
1293 SafeTransaction {
1294 to: Address::from([0x01; 20]),
1295 operation: 0,
1296 data: alloy::primitives::Bytes::from(vec![0x01]),
1297 value: U256::ZERO,
1298 },
1299 SafeTransaction {
1300 to: Address::from([0x02; 20]),
1301 operation: 0,
1302 data: alloy::primitives::Bytes::from(vec![0x02]),
1303 value: U256::ZERO,
1304 },
1305 ];
1306 let result = client.create_safe_multisend_transaction(&txns);
1307 assert_eq!(result.operation, 1);
1309 assert_eq!(result.to, client.contract_config.safe_multisend);
1310 assert_eq!(result.value, U256::ZERO);
1311 let data_hex = hex::encode(&result.data);
1313 assert!(
1314 data_hex.starts_with("8d80ff0a"),
1315 "Expected multiSend selector, got: {}",
1316 &data_hex[..8.min(data_hex.len())]
1317 );
1318 }
1319}