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