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