1use crate::account::BuilderAccount;
2use crate::config::{get_contract_config, AuthConfig, BuilderConfig, ContractConfig};
3use crate::error::RelayError;
4use crate::types::{
5 NonceResponse, RelayerApiKey, RelayerTransaction, SafeTransaction, SafeTx, SubmitResponse,
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 fn authed_get_headers(
185 &self,
186 path: &str,
187 allow_builder: bool,
188 allow_relayer_api_key: bool,
189 ) -> Result<reqwest::header::HeaderMap, RelayError> {
190 let account = self.account.as_ref().ok_or_else(|| {
191 RelayError::Api(
192 "Account missing - cannot authenticate request. Configure an account via RelayClientBuilder::with_account or ::relayer_api_key.".to_string(),
193 )
194 })?;
195 let auth = account.auth_config().ok_or_else(|| {
196 RelayError::Api(
197 "No authentication configured - provide BuilderConfig or RelayerApiKeyConfig when creating the BuilderAccount".to_string(),
198 )
199 })?;
200
201 match auth {
202 AuthConfig::Builder(cfg) => {
203 if !allow_builder {
204 return Err(RelayError::Api(format!(
205 "{} requires Relayer API Key auth; configure the client with relayer_api_key()",
206 path
207 )));
208 }
209 cfg.generate_relayer_v2_headers("GET", path, None)
210 .map_err(RelayError::Api)
211 }
212 AuthConfig::RelayerApiKey(cfg) => {
213 if !allow_relayer_api_key {
214 return Err(RelayError::Api(format!(
215 "{} requires Builder HMAC auth; configure the client with BuilderConfig",
216 path
217 )));
218 }
219 cfg.generate_headers().map_err(RelayError::Api)
220 }
221 }
222 }
223
224 async fn get_with_retry_authed(
226 &self,
227 path: &str,
228 url: &Url,
229 allow_builder: bool,
230 allow_relayer_api_key: bool,
231 ) -> Result<reqwest::Response, RelayError> {
232 let mut attempt = 0u32;
233 loop {
234 let _permit = self.http_client.acquire_concurrency().await;
235 self.http_client.acquire_rate_limit(path, None).await;
236
237 let headers = self.authed_get_headers(path, allow_builder, allow_relayer_api_key)?;
239 let resp = self
240 .http_client
241 .client
242 .get(url.clone())
243 .headers(headers)
244 .send()
245 .await?;
246
247 let retry_after = retry_after_header(&resp);
248 if let Some(backoff) =
249 self.http_client
250 .should_retry(resp.status(), attempt, retry_after.as_deref())
251 {
252 attempt += 1;
253 tracing::warn!(
254 "Rate limited (429) on {}, retry {} after {}ms",
255 path,
256 attempt,
257 backoff.as_millis()
258 );
259 drop(_permit);
260 tokio::time::sleep(backoff).await;
261 continue;
262 }
263
264 if !resp.status().is_success() {
265 let text = resp.text().await?;
266 return Err(RelayError::Api(format!("{} failed: {}", path, text)));
267 }
268
269 return Ok(resp);
270 }
271 }
272
273 pub async fn ping(&self) -> Result<Duration, RelayError> {
290 let url = self.http_client.base_url.clone();
291 let start = Instant::now();
292 let _resp = self.get_with_retry("/", &url).await?;
293 Ok(start.elapsed())
294 }
295
296 pub async fn get_nonce(&self, address: Address) -> Result<u64, RelayError> {
298 let url = self.http_client.base_url.join(&format!(
299 "nonce?address={}&type={}",
300 address,
301 self.wallet_type.as_str()
302 ))?;
303 let resp = self.get_with_retry("/nonce", &url).await?;
304 let data = resp.json::<NonceResponse>().await?;
305 Ok(data.nonce)
306 }
307
308 pub async fn get_transaction(
310 &self,
311 transaction_id: &str,
312 ) -> Result<RelayerTransaction, RelayError> {
313 let url = self
314 .http_client
315 .base_url
316 .join(&format!("transaction?id={}", transaction_id))?;
317 let resp = self.get_with_retry("/transaction", &url).await?;
318 resp.json::<RelayerTransaction>().await.map_err(Into::into)
319 }
320
321 pub async fn list_transactions(&self) -> Result<Vec<RelayerTransaction>, RelayError> {
331 let url = self.http_client.base_url.join("transactions")?;
332 let resp = self
333 .get_with_retry_authed("/transactions", &url, true, true)
334 .await?;
335 resp.json::<Vec<RelayerTransaction>>()
336 .await
337 .map_err(Into::into)
338 }
339
340 pub async fn list_relayer_api_keys(&self) -> Result<Vec<RelayerApiKey>, RelayError> {
348 let url = self.http_client.base_url.join("relayer/api/keys")?;
349 let resp = self
350 .get_with_retry_authed("/relayer/api/keys", &url, false, true)
351 .await?;
352 resp.json::<Vec<RelayerApiKey>>().await.map_err(Into::into)
353 }
354
355 pub async fn get_deployed(&self, safe_address: Address) -> Result<bool, RelayError> {
357 #[derive(serde::Deserialize)]
358 struct DeployedResponse {
359 deployed: bool,
360 }
361 let url = self
362 .http_client
363 .base_url
364 .join(&format!("deployed?address={}", safe_address))?;
365 let resp = self.get_with_retry("/deployed", &url).await?;
366 let data = resp.json::<DeployedResponse>().await?;
367 Ok(data.deployed)
368 }
369
370 fn derive_safe_address(&self, owner: Address) -> Address {
371 let salt = keccak256(owner.abi_encode());
372 let init_code_hash = hex::decode(SAFE_INIT_CODE_HASH).expect("valid hex constant");
373
374 let mut input = Vec::new();
376 input.push(0xff);
377 input.extend_from_slice(self.contract_config.safe_factory.as_slice());
378 input.extend_from_slice(salt.as_slice());
379 input.extend_from_slice(&init_code_hash);
380
381 let hash = keccak256(input);
382 Address::from_slice(&hash[12..])
383 }
384
385 pub fn get_expected_safe(&self) -> Result<Address, RelayError> {
387 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
388 Ok(self.derive_safe_address(account.address()))
389 }
390
391 fn derive_proxy_wallet(&self, owner: Address) -> Result<Address, RelayError> {
392 let proxy_factory = self.contract_config.proxy_factory.ok_or_else(|| {
393 RelayError::Api("Proxy wallet not supported on this chain".to_string())
394 })?;
395
396 let salt = keccak256(owner.as_slice());
399
400 let init_code_hash = hex::decode(PROXY_INIT_CODE_HASH).expect("valid hex constant");
401
402 let mut input = Vec::new();
404 input.push(0xff);
405 input.extend_from_slice(proxy_factory.as_slice());
406 input.extend_from_slice(salt.as_slice());
407 input.extend_from_slice(&init_code_hash);
408
409 let hash = keccak256(input);
410 Ok(Address::from_slice(&hash[12..]))
411 }
412
413 pub fn get_expected_proxy_wallet(&self) -> Result<Address, RelayError> {
415 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
416 self.derive_proxy_wallet(account.address())
417 }
418
419 pub async fn get_relay_payload(&self, address: Address) -> Result<(Address, u64), RelayError> {
421 #[derive(serde::Deserialize)]
422 struct RelayPayload {
423 address: String,
424 #[serde(deserialize_with = "crate::types::deserialize_nonce")]
425 nonce: u64,
426 }
427
428 let url = self
429 .http_client
430 .base_url
431 .join(&format!("relay-payload?address={}&type=PROXY", address))?;
432 let resp = self.get_with_retry("/relay-payload", &url).await?;
433 let data = resp.json::<RelayPayload>().await?;
434 let relay_address: Address = data
435 .address
436 .parse()
437 .map_err(|e| RelayError::Api(format!("Invalid relay address: {}", e)))?;
438 Ok((relay_address, data.nonce))
439 }
440
441 #[allow(clippy::too_many_arguments)]
443 fn create_proxy_struct_hash(
444 &self,
445 from: Address,
446 to: Address,
447 data: &[u8],
448 tx_fee: U256,
449 gas_price: U256,
450 gas_limit: U256,
451 nonce: u64,
452 relay_hub: Address,
453 relay: Address,
454 ) -> [u8; 32] {
455 let mut message = Vec::new();
456
457 message.extend_from_slice(b"rlx:");
459 message.extend_from_slice(from.as_slice());
461 message.extend_from_slice(to.as_slice());
463 message.extend_from_slice(data);
465 message.extend_from_slice(&tx_fee.to_be_bytes::<32>());
467 message.extend_from_slice(&gas_price.to_be_bytes::<32>());
469 message.extend_from_slice(&gas_limit.to_be_bytes::<32>());
471 message.extend_from_slice(&U256::from(nonce).to_be_bytes::<32>());
473 message.extend_from_slice(relay_hub.as_slice());
475 message.extend_from_slice(relay.as_slice());
477
478 keccak256(&message).into()
479 }
480
481 fn encode_proxy_transaction_data(&self, txns: &[SafeTransaction]) -> Vec<u8> {
483 alloy::sol! {
487 struct ProxyTransaction {
488 uint8 typeCode;
489 address to;
490 uint256 value;
491 bytes data;
492 }
493 function proxy(ProxyTransaction[] txns);
494 }
495
496 let proxy_txns: Vec<ProxyTransaction> = txns
497 .iter()
498 .map(|tx| ProxyTransaction {
499 typeCode: PROXY_CALL_TYPE_CODE,
500 to: tx.to,
501 value: tx.value,
502 data: tx.data.clone(),
503 })
504 .collect();
505
506 let call = proxyCall { txns: proxy_txns };
508 call.abi_encode()
509 }
510
511 fn create_safe_multisend_transaction(&self, txns: &[SafeTransaction]) -> SafeTransaction {
512 if txns.len() == 1 {
513 return txns[0].clone();
514 }
515
516 let mut encoded_txns = Vec::new();
517 for tx in txns {
518 let mut packed = Vec::new();
520 packed.push(tx.operation);
521 packed.extend_from_slice(tx.to.as_slice());
522 packed.extend_from_slice(&tx.value.to_be_bytes::<32>());
523 packed.extend_from_slice(&U256::from(tx.data.len()).to_be_bytes::<32>());
524 packed.extend_from_slice(&tx.data);
525 encoded_txns.extend_from_slice(&packed);
526 }
527
528 let mut data = MULTISEND_SELECTOR.to_vec();
529
530 let multisend_data = (Bytes::from(encoded_txns),).abi_encode();
532 data.extend_from_slice(&multisend_data);
533
534 SafeTransaction {
535 to: self.contract_config.safe_multisend,
536 operation: DELEGATE_CALL_OPERATION,
537 data: data.into(),
538 value: U256::ZERO,
539 }
540 }
541
542 fn split_and_pack_sig_safe(&self, sig: alloy::primitives::Signature) -> String {
543 let v_raw = if sig.v() { 1u8 } else { 0u8 };
546 let v = v_raw + 31;
547
548 let mut packed = Vec::new();
550 packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
551 packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
552 packed.push(v);
553
554 format!("0x{}", hex::encode(packed))
555 }
556
557 fn split_and_pack_sig_proxy(&self, sig: alloy::primitives::Signature) -> String {
558 let v = if sig.v() { 28u8 } else { 27u8 };
560
561 let mut packed = Vec::new();
563 packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
564 packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
565 packed.push(v);
566
567 format!("0x{}", hex::encode(packed))
568 }
569
570 pub async fn execute(
572 &self,
573 transactions: Vec<SafeTransaction>,
574 metadata: Option<String>,
575 ) -> Result<SubmitResponse, RelayError> {
576 self.execute_with_gas(transactions, metadata, None).await
577 }
578
579 pub async fn execute_with_gas(
584 &self,
585 transactions: Vec<SafeTransaction>,
586 metadata: Option<String>,
587 gas_limit: Option<u64>,
588 ) -> Result<SubmitResponse, RelayError> {
589 if transactions.is_empty() {
590 return Err(RelayError::Api("No transactions to execute".into()));
591 }
592 match self.wallet_type {
593 WalletType::Safe => self.execute_safe(transactions, metadata).await,
594 WalletType::Proxy => self.execute_proxy(transactions, metadata, gas_limit).await,
595 }
596 }
597
598 async fn execute_safe(
599 &self,
600 transactions: Vec<SafeTransaction>,
601 metadata: Option<String>,
602 ) -> Result<SubmitResponse, RelayError> {
603 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
604 let from_address = account.address();
605
606 let safe_address = self.derive_safe_address(from_address);
607
608 if !self.get_deployed(safe_address).await? {
609 return Err(RelayError::Api(format!(
610 "Safe {} is not deployed",
611 safe_address
612 )));
613 }
614
615 let nonce = self.get_nonce(from_address).await?;
616
617 let aggregated = self.create_safe_multisend_transaction(&transactions);
618
619 let safe_tx = SafeTx {
620 to: aggregated.to,
621 value: aggregated.value,
622 data: aggregated.data,
623 operation: aggregated.operation,
624 safeTxGas: U256::ZERO,
625 baseGas: U256::ZERO,
626 gasPrice: U256::ZERO,
627 gasToken: Address::ZERO,
628 refundReceiver: Address::ZERO,
629 nonce: U256::from(nonce),
630 };
631
632 let domain = Eip712Domain {
633 name: None,
634 version: None,
635 chain_id: Some(U256::from(self.chain_id)),
636 verifying_contract: Some(safe_address),
637 salt: None,
638 };
639
640 let struct_hash = safe_tx.eip712_signing_hash(&domain);
641 let signature = account
642 .signer()
643 .sign_message(struct_hash.as_slice())
644 .await
645 .map_err(|e| RelayError::Signer(e.to_string()))?;
646 let packed_sig = self.split_and_pack_sig_safe(signature);
647
648 let body = SafeSubmitBody {
649 type_: "SAFE".to_string(),
650 from: from_address.to_string(),
651 to: safe_tx.to.to_string(),
652 proxy_wallet: safe_address.to_string(),
653 data: safe_tx.data.to_string(),
654 signature: packed_sig,
655 signature_params: SafeSigParams {
656 gas_price: "0".to_string(),
657 operation: safe_tx.operation.to_string(),
658 safe_tx_gas: "0".to_string(),
659 base_gas: "0".to_string(),
660 gas_token: Address::ZERO.to_string(),
661 refund_receiver: Address::ZERO.to_string(),
662 },
663 value: safe_tx.value.to_string(),
664 nonce: nonce.to_string(),
665 metadata,
666 };
667
668 self._post_request("submit", &body).await
669 }
670
671 async fn execute_proxy(
672 &self,
673 transactions: Vec<SafeTransaction>,
674 metadata: Option<String>,
675 gas_limit: Option<u64>,
676 ) -> Result<SubmitResponse, RelayError> {
677 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
678 let from_address = account.address();
679
680 let proxy_wallet = self.derive_proxy_wallet(from_address)?;
681 let relay_hub = self
682 .contract_config
683 .relay_hub
684 .ok_or_else(|| RelayError::Api("Relay hub not configured".to_string()))?;
685 let proxy_factory = self
686 .contract_config
687 .proxy_factory
688 .ok_or_else(|| RelayError::Api("Proxy factory not configured".to_string()))?;
689
690 let (relay_address, nonce) = self.get_relay_payload(from_address).await?;
692
693 let encoded_data = self.encode_proxy_transaction_data(&transactions);
695
696 let tx_fee = U256::ZERO;
698 let gas_price = U256::ZERO;
699 let gas_limit = U256::from(gas_limit.unwrap_or(10_000_000u64));
700
701 let struct_hash = self.create_proxy_struct_hash(
703 from_address,
704 proxy_factory,
705 &encoded_data,
706 tx_fee,
707 gas_price,
708 gas_limit,
709 nonce,
710 relay_hub,
711 relay_address,
712 );
713
714 let signature = account
716 .signer()
717 .sign_message(&struct_hash)
718 .await
719 .map_err(|e| RelayError::Signer(e.to_string()))?;
720 let packed_sig = self.split_and_pack_sig_proxy(signature);
721
722 let body = ProxySubmitBody {
723 type_: "PROXY".to_string(),
724 from: from_address.to_string(),
725 to: proxy_factory.to_string(),
726 proxy_wallet: proxy_wallet.to_string(),
727 data: format!("0x{}", hex::encode(&encoded_data)),
728 signature: packed_sig,
729 signature_params: ProxySigParams {
730 relayer_fee: "0".to_string(),
731 gas_limit: gas_limit.to_string(),
732 gas_price: "0".to_string(),
733 relay_hub: relay_hub.to_string(),
734 relay: relay_address.to_string(),
735 },
736 nonce: nonce.to_string(),
737 metadata,
738 };
739
740 self._post_request("submit", &body).await
741 }
742
743 pub async fn estimate_redemption_gas(
781 &self,
782 condition_id: [u8; 32],
783 index_sets: Vec<U256>,
784 ) -> Result<u64, RelayError> {
785 alloy::sol! {
787 function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
788 }
789
790 let collateral =
792 Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None)
793 .map_err(|e| RelayError::Api(format!("Invalid collateral address: {}", e)))?;
794 let ctf_exchange =
795 Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None)
796 .map_err(|e| RelayError::Api(format!("Invalid CTF exchange address: {}", e)))?;
797 let parent_collection_id = [0u8; 32];
798
799 let call = redeemPositionsCall {
801 collateral,
802 parentCollectionId: parent_collection_id.into(),
803 conditionId: condition_id.into(),
804 indexSets: index_sets,
805 };
806 let redemption_calldata = Bytes::from(call.abi_encode());
807
808 let proxy_wallet = match self.wallet_type {
810 WalletType::Proxy => self.get_expected_proxy_wallet()?,
811 WalletType::Safe => self.get_expected_safe()?,
812 };
813
814 let provider = ProviderBuilder::new().connect_http(
816 self.contract_config
817 .rpc_url
818 .parse()
819 .map_err(|e| RelayError::Api(format!("Invalid RPC URL: {}", e)))?,
820 );
821
822 let tx = TransactionRequest::default()
824 .with_from(proxy_wallet)
825 .with_to(ctf_exchange)
826 .with_input(redemption_calldata);
827
828 let inner_gas_used = provider
830 .estimate_gas(tx)
831 .await
832 .map_err(|e| RelayError::Api(format!("Gas estimation failed: {}", e)))?;
833
834 let relayer_overhead: u64 = 50_000;
836 let safe_gas_limit = (inner_gas_used + relayer_overhead) * 120 / 100;
837
838 Ok(safe_gas_limit)
839 }
840
841 pub async fn submit_gasless_redemption(
843 &self,
844 condition_id: [u8; 32],
845 index_sets: Vec<alloy::primitives::U256>,
846 ) -> Result<SubmitResponse, RelayError> {
847 self.submit_gasless_redemption_with_gas_estimation(condition_id, index_sets, false)
848 .await
849 }
850
851 pub async fn submit_gasless_redemption_with_gas_estimation(
856 &self,
857 condition_id: [u8; 32],
858 index_sets: Vec<alloy::primitives::U256>,
859 estimate_gas: bool,
860 ) -> Result<SubmitResponse, RelayError> {
861 alloy::sol! {
863 function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
864 }
865
866 let collateral =
869 Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None)
870 .map_err(|e| RelayError::Api(format!("Invalid address: {}", e)))?;
871 let ctf_exchange =
873 Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None)
874 .map_err(|e| RelayError::Api(format!("Invalid address: {}", e)))?;
875 let parent_collection_id = [0u8; 32];
876
877 let call = redeemPositionsCall {
879 collateral,
880 parentCollectionId: parent_collection_id.into(),
881 conditionId: condition_id.into(),
882 indexSets: index_sets.clone(),
883 };
884 let data = call.abi_encode();
885
886 let gas_limit = if estimate_gas {
888 Some(
889 self.estimate_redemption_gas(condition_id, index_sets.clone())
890 .await?,
891 )
892 } else {
893 None
894 };
895
896 let tx = SafeTransaction {
898 to: ctf_exchange,
899 value: U256::ZERO,
900 data: data.into(),
901 operation: CALL_OPERATION,
902 };
903
904 self.execute_with_gas(vec![tx], None, gas_limit).await
907 }
908
909 async fn _post_request<T: Serialize>(
910 &self,
911 endpoint: &str,
912 body: &T,
913 ) -> Result<SubmitResponse, RelayError> {
914 let url = self.http_client.base_url.join(endpoint)?;
915 let body_str = serde_json::to_string(body)?;
916 let path = format!("/{}", endpoint);
917 let mut attempt = 0u32;
918
919 loop {
920 let _permit = self.http_client.acquire_concurrency().await;
921 self.http_client
922 .acquire_rate_limit(&path, Some(&reqwest::Method::POST))
923 .await;
924
925 let mut headers = if let Some(account) = &self.account {
927 if let Some(auth) = account.auth_config() {
928 auth.generate_relayer_v2_headers("POST", url.path(), Some(&body_str))
929 .map_err(RelayError::Api)?
930 } else {
931 return Err(RelayError::Api(
932 "No authentication configured - provide BuilderConfig or RelayerApiKeyConfig when creating the BuilderAccount".to_string(),
933 ));
934 }
935 } else {
936 return Err(RelayError::Api(
937 "Account missing - cannot authenticate request".to_string(),
938 ));
939 };
940
941 headers.insert(
942 reqwest::header::CONTENT_TYPE,
943 reqwest::header::HeaderValue::from_static("application/json"),
944 );
945
946 let resp = self
947 .http_client
948 .client
949 .post(url.clone())
950 .headers(headers)
951 .body(body_str.clone())
952 .send()
953 .await?;
954
955 let status = resp.status();
956 let retry_after = retry_after_header(&resp);
957 tracing::debug!("Response status for {}: {}", endpoint, status);
958
959 if let Some(backoff) =
960 self.http_client
961 .should_retry(status, attempt, retry_after.as_deref())
962 {
963 attempt += 1;
964 tracing::warn!(
965 "Rate limited (429) on {}, retry {} after {}ms",
966 endpoint,
967 attempt,
968 backoff.as_millis()
969 );
970 drop(_permit);
971 tokio::time::sleep(backoff).await;
972 continue;
973 }
974
975 if !status.is_success() {
976 let text = resp.text().await?;
977 tracing::error!(
978 "Request to {} failed with status {}: {}",
979 endpoint,
980 status,
981 polyoxide_core::truncate_for_log(&text)
982 );
983 return Err(RelayError::Api(format!("Request failed: {}", text)));
984 }
985
986 let response_text = resp.text().await?;
987
988 return serde_json::from_str(&response_text).map_err(|e| {
990 tracing::error!(
991 "Failed to decode response from {}: {}. Raw body: {}",
992 endpoint,
993 e,
994 polyoxide_core::truncate_for_log(&response_text)
995 );
996 RelayError::SerdeJson(e)
997 });
998 }
999 }
1000}
1001
1002pub struct RelayClientBuilder {
1007 base_url: String,
1008 chain_id: u64,
1009 account: Option<BuilderAccount>,
1010 wallet_type: WalletType,
1011 retry_config: Option<RetryConfig>,
1012 max_concurrent: Option<usize>,
1013}
1014
1015impl Default for RelayClientBuilder {
1016 fn default() -> Self {
1017 let relayer_url = std::env::var("RELAYER_URL")
1018 .unwrap_or_else(|_| "https://relayer-v2.polymarket.com/".to_string());
1019 let chain_id = std::env::var("CHAIN_ID")
1020 .unwrap_or("137".to_string())
1021 .parse::<u64>()
1022 .unwrap_or(137);
1023
1024 Self::new()
1025 .expect("default URL is valid")
1026 .url(&relayer_url)
1027 .expect("default URL is valid")
1028 .chain_id(chain_id)
1029 }
1030}
1031
1032impl RelayClientBuilder {
1033 pub fn new() -> Result<Self, RelayError> {
1035 let mut base_url = Url::parse("https://relayer-v2.polymarket.com")?;
1036 if !base_url.path().ends_with('/') {
1037 base_url.set_path(&format!("{}/", base_url.path()));
1038 }
1039
1040 Ok(Self {
1041 base_url: base_url.to_string(),
1042 chain_id: 137,
1043 account: None,
1044 wallet_type: WalletType::default(),
1045 retry_config: None,
1046 max_concurrent: None,
1047 })
1048 }
1049
1050 pub fn chain_id(mut self, chain_id: u64) -> Self {
1052 self.chain_id = chain_id;
1053 self
1054 }
1055
1056 pub fn url(mut self, url: &str) -> Result<Self, RelayError> {
1058 let mut base_url = Url::parse(url)?;
1059 if !base_url.path().ends_with('/') {
1060 base_url.set_path(&format!("{}/", base_url.path()));
1061 }
1062 self.base_url = base_url.to_string();
1063 Ok(self)
1064 }
1065
1066 pub fn with_account(mut self, account: BuilderAccount) -> Self {
1068 self.account = Some(account);
1069 self
1070 }
1071
1072 pub fn relayer_api_key(
1078 self,
1079 private_key: impl Into<String>,
1080 key: String,
1081 address: String,
1082 ) -> Result<Self, RelayError> {
1083 let account = BuilderAccount::with_relayer_api_key(private_key, key, address)?;
1084 Ok(self.with_account(account))
1085 }
1086
1087 pub fn wallet_type(mut self, wallet_type: WalletType) -> Self {
1089 self.wallet_type = wallet_type;
1090 self
1091 }
1092
1093 pub fn with_retry_config(mut self, config: RetryConfig) -> Self {
1095 self.retry_config = Some(config);
1096 self
1097 }
1098
1099 pub fn max_concurrent(mut self, max: usize) -> Self {
1103 self.max_concurrent = Some(max);
1104 self
1105 }
1106
1107 pub fn build(self) -> Result<RelayClient, RelayError> {
1111 let mut base_url = Url::parse(&self.base_url)?;
1112 if !base_url.path().ends_with('/') {
1113 base_url.set_path(&format!("{}/", base_url.path()));
1114 }
1115
1116 let contract_config = get_contract_config(self.chain_id)
1117 .ok_or_else(|| RelayError::Api(format!("Unsupported chain ID: {}", self.chain_id)))?;
1118
1119 let mut builder = HttpClientBuilder::new(base_url.as_str())
1120 .with_rate_limiter(RateLimiter::relay_default())
1121 .with_max_concurrent(self.max_concurrent.unwrap_or(2));
1122 if let Some(config) = self.retry_config {
1123 builder = builder.with_retry_config(config);
1124 }
1125 let http_client = builder.build()?;
1126
1127 Ok(RelayClient {
1128 http_client,
1129 chain_id: self.chain_id,
1130 account: self.account,
1131 contract_config,
1132 wallet_type: self.wallet_type,
1133 })
1134 }
1135}
1136
1137#[cfg(test)]
1138mod tests {
1139 use super::*;
1140
1141 #[tokio::test]
1142 async fn test_ping() {
1143 let client = RelayClient::builder().unwrap().build().unwrap();
1144 let result = client.ping().await;
1145 assert!(result.is_ok(), "ping failed: {:?}", result.err());
1146 }
1147
1148 #[tokio::test]
1149 async fn test_default_concurrency_limit_is_2() {
1150 let client = RelayClient::builder().unwrap().build().unwrap();
1151 let mut permits = Vec::new();
1152 for _ in 0..2 {
1153 permits.push(client.http_client.acquire_concurrency().await);
1154 }
1155 assert!(permits.iter().all(|p| p.is_some()));
1156
1157 let result = tokio::time::timeout(
1158 std::time::Duration::from_millis(50),
1159 client.http_client.acquire_concurrency(),
1160 )
1161 .await;
1162 assert!(
1163 result.is_err(),
1164 "3rd permit should block with default limit of 2"
1165 );
1166 }
1167
1168 #[test]
1169 fn test_hex_constants_are_valid() {
1170 hex::decode(SAFE_INIT_CODE_HASH).expect("SAFE_INIT_CODE_HASH should be valid hex");
1171 hex::decode(PROXY_INIT_CODE_HASH).expect("PROXY_INIT_CODE_HASH should be valid hex");
1172 }
1173
1174 #[test]
1175 fn test_multisend_selector_matches_expected() {
1176 assert_eq!(MULTISEND_SELECTOR, [0x8d, 0x80, 0xff, 0x0a]);
1178 }
1179
1180 #[test]
1181 fn test_operation_constants() {
1182 assert_eq!(CALL_OPERATION, 0);
1183 assert_eq!(DELEGATE_CALL_OPERATION, 1);
1184 assert_eq!(PROXY_CALL_TYPE_CODE, 1);
1185 }
1186
1187 #[test]
1188 fn test_contract_config_polygon_mainnet() {
1189 let config = get_contract_config(137);
1190 assert!(config.is_some(), "should return config for Polygon mainnet");
1191 let config = config.unwrap();
1192 assert!(config.proxy_factory.is_some());
1193 assert!(config.relay_hub.is_some());
1194 }
1195
1196 #[test]
1197 fn test_contract_config_amoy_testnet() {
1198 let config = get_contract_config(80002);
1199 assert!(config.is_some(), "should return config for Amoy testnet");
1200 let config = config.unwrap();
1201 assert!(
1202 config.proxy_factory.is_none(),
1203 "proxy not supported on Amoy"
1204 );
1205 assert!(
1206 config.relay_hub.is_none(),
1207 "relay hub not supported on Amoy"
1208 );
1209 }
1210
1211 #[test]
1212 fn test_contract_config_unknown_chain() {
1213 assert!(get_contract_config(999).is_none());
1214 }
1215
1216 #[test]
1217 fn test_relay_client_builder_default() {
1218 let builder = RelayClientBuilder::default();
1219 assert_eq!(builder.chain_id, 137);
1220 }
1221
1222 #[test]
1223 fn test_builder_custom_retry_config() {
1224 let config = RetryConfig {
1225 max_retries: 5,
1226 initial_backoff_ms: 1000,
1227 max_backoff_ms: 30_000,
1228 };
1229 let builder = RelayClientBuilder::new().unwrap().with_retry_config(config);
1230 let config = builder.retry_config.unwrap();
1231 assert_eq!(config.max_retries, 5);
1232 assert_eq!(config.initial_backoff_ms, 1000);
1233 }
1234
1235 #[test]
1238 fn test_builder_unsupported_chain() {
1239 let result = RelayClient::builder().unwrap().chain_id(999).build();
1240 assert!(result.is_err());
1241 let err_msg = format!("{}", result.unwrap_err());
1242 assert!(
1243 err_msg.contains("Unsupported chain ID"),
1244 "Expected unsupported chain error, got: {err_msg}"
1245 );
1246 }
1247
1248 #[test]
1249 fn test_builder_with_wallet_type() {
1250 let client = RelayClient::builder()
1251 .unwrap()
1252 .wallet_type(WalletType::Proxy)
1253 .build()
1254 .unwrap();
1255 assert_eq!(client.wallet_type, WalletType::Proxy);
1256 }
1257
1258 #[test]
1259 fn test_builder_no_account_address_is_none() {
1260 let client = RelayClient::builder().unwrap().build().unwrap();
1261 assert!(client.address().is_none());
1262 }
1263
1264 #[test]
1265 fn test_builder_relayer_api_key_attaches_account() {
1266 let client = RelayClient::builder()
1267 .unwrap()
1268 .relayer_api_key(
1269 "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
1270 "my-relayer-key".to_string(),
1271 "0xabc123".to_string(),
1272 )
1273 .unwrap()
1274 .build()
1275 .unwrap();
1276 let account = client.account.as_ref().expect("account should be attached");
1277 assert!(matches!(
1278 account.auth_config(),
1279 Some(crate::config::AuthConfig::RelayerApiKey(_))
1280 ));
1281 }
1282
1283 const TEST_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1287
1288 fn test_client_with_account() -> RelayClient {
1289 let account = crate::BuilderAccount::new(TEST_KEY, None).unwrap();
1290 RelayClient::builder()
1291 .unwrap()
1292 .with_account(account)
1293 .build()
1294 .unwrap()
1295 }
1296
1297 #[test]
1298 fn test_derive_safe_address_deterministic() {
1299 let client = test_client_with_account();
1300 let addr1 = client.get_expected_safe().unwrap();
1301 let addr2 = client.get_expected_safe().unwrap();
1302 assert_eq!(addr1, addr2);
1303 }
1304
1305 #[test]
1306 fn test_derive_safe_address_nonzero() {
1307 let client = test_client_with_account();
1308 let addr = client.get_expected_safe().unwrap();
1309 assert_ne!(addr, Address::ZERO);
1310 }
1311
1312 #[test]
1313 fn test_derive_proxy_wallet_deterministic() {
1314 let client = test_client_with_account();
1315 let addr1 = client.get_expected_proxy_wallet().unwrap();
1316 let addr2 = client.get_expected_proxy_wallet().unwrap();
1317 assert_eq!(addr1, addr2);
1318 }
1319
1320 #[test]
1321 fn test_safe_and_proxy_addresses_differ() {
1322 let client = test_client_with_account();
1323 let safe = client.get_expected_safe().unwrap();
1324 let proxy = client.get_expected_proxy_wallet().unwrap();
1325 assert_ne!(safe, proxy);
1326 }
1327
1328 #[test]
1329 fn test_derive_proxy_wallet_no_account() {
1330 let client = RelayClient::builder().unwrap().build().unwrap();
1331 let result = client.get_expected_proxy_wallet();
1332 assert!(result.is_err());
1333 }
1334
1335 #[test]
1336 fn test_derive_proxy_wallet_amoy_unsupported() {
1337 let account = crate::BuilderAccount::new(TEST_KEY, None).unwrap();
1338 let client = RelayClient::builder()
1339 .unwrap()
1340 .chain_id(80002)
1341 .with_account(account)
1342 .build()
1343 .unwrap();
1344 let result = client.get_expected_proxy_wallet();
1346 assert!(result.is_err());
1347 }
1348
1349 #[test]
1352 fn test_split_and_pack_sig_safe_format() {
1353 let client = test_client_with_account();
1354 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1356 alloy::primitives::B256::from([1u8; 32]),
1357 alloy::primitives::B256::from([2u8; 32]),
1358 false, );
1360 let packed = client.split_and_pack_sig_safe(sig);
1361 assert!(packed.starts_with("0x"));
1362 assert_eq!(packed.len(), 132);
1364 assert!(packed.ends_with("1f"), "expected v=31(0x1f), got: {packed}");
1366 }
1367
1368 #[test]
1369 fn test_split_and_pack_sig_safe_v_true() {
1370 let client = test_client_with_account();
1371 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1372 alloy::primitives::B256::from([0xAA; 32]),
1373 alloy::primitives::B256::from([0xBB; 32]),
1374 true, );
1376 let packed = client.split_and_pack_sig_safe(sig);
1377 assert!(packed.ends_with("20"), "expected v=32(0x20), got: {packed}");
1379 }
1380
1381 #[test]
1382 fn test_split_and_pack_sig_proxy_format() {
1383 let client = test_client_with_account();
1384 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1385 alloy::primitives::B256::from([1u8; 32]),
1386 alloy::primitives::B256::from([2u8; 32]),
1387 false, );
1389 let packed = client.split_and_pack_sig_proxy(sig);
1390 assert!(packed.starts_with("0x"));
1391 assert_eq!(packed.len(), 132);
1392 assert!(packed.ends_with("1b"), "expected v=27(0x1b), got: {packed}");
1394 }
1395
1396 #[test]
1397 fn test_split_and_pack_sig_proxy_v_true() {
1398 let client = test_client_with_account();
1399 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1400 alloy::primitives::B256::from([0xAA; 32]),
1401 alloy::primitives::B256::from([0xBB; 32]),
1402 true, );
1404 let packed = client.split_and_pack_sig_proxy(sig);
1405 assert!(packed.ends_with("1c"), "expected v=28(0x1c), got: {packed}");
1407 }
1408
1409 #[test]
1412 fn test_encode_proxy_transaction_data_single() {
1413 let client = test_client_with_account();
1414 let txns = vec![SafeTransaction {
1415 to: Address::ZERO,
1416 operation: 0,
1417 data: alloy::primitives::Bytes::from(vec![0xde, 0xad]),
1418 value: U256::ZERO,
1419 }];
1420 let encoded = client.encode_proxy_transaction_data(&txns);
1421 assert!(
1423 encoded.len() >= 4,
1424 "encoded data too short: {} bytes",
1425 encoded.len()
1426 );
1427 }
1428
1429 #[test]
1430 fn test_encode_proxy_transaction_data_multiple() {
1431 let client = test_client_with_account();
1432 let txns = vec![
1433 SafeTransaction {
1434 to: Address::ZERO,
1435 operation: 0,
1436 data: alloy::primitives::Bytes::from(vec![0x01]),
1437 value: U256::ZERO,
1438 },
1439 SafeTransaction {
1440 to: Address::ZERO,
1441 operation: 0,
1442 data: alloy::primitives::Bytes::from(vec![0x02]),
1443 value: U256::from(100),
1444 },
1445 ];
1446 let encoded = client.encode_proxy_transaction_data(&txns);
1447 assert!(encoded.len() >= 4);
1448 let single = client.encode_proxy_transaction_data(&txns[..1]);
1450 assert!(encoded.len() > single.len());
1451 }
1452
1453 #[test]
1454 fn test_encode_proxy_transaction_data_empty() {
1455 let client = test_client_with_account();
1456 let encoded = client.encode_proxy_transaction_data(&[]);
1457 assert!(encoded.len() >= 4);
1459 }
1460
1461 #[test]
1464 fn test_multisend_single_returns_same() {
1465 let client = test_client_with_account();
1466 let tx = SafeTransaction {
1467 to: Address::from([0x42; 20]),
1468 operation: 0,
1469 data: alloy::primitives::Bytes::from(vec![0xAB]),
1470 value: U256::from(99),
1471 };
1472 let result = client.create_safe_multisend_transaction(std::slice::from_ref(&tx));
1473 assert_eq!(result.to, tx.to);
1474 assert_eq!(result.value, tx.value);
1475 assert_eq!(result.data, tx.data);
1476 assert_eq!(result.operation, tx.operation);
1477 }
1478
1479 #[test]
1480 fn test_multisend_multiple_uses_delegate_call() {
1481 let client = test_client_with_account();
1482 let txns = vec![
1483 SafeTransaction {
1484 to: Address::from([0x01; 20]),
1485 operation: 0,
1486 data: alloy::primitives::Bytes::from(vec![0x01]),
1487 value: U256::ZERO,
1488 },
1489 SafeTransaction {
1490 to: Address::from([0x02; 20]),
1491 operation: 0,
1492 data: alloy::primitives::Bytes::from(vec![0x02]),
1493 value: U256::ZERO,
1494 },
1495 ];
1496 let result = client.create_safe_multisend_transaction(&txns);
1497 assert_eq!(result.operation, 1);
1499 assert_eq!(result.to, client.contract_config.safe_multisend);
1500 assert_eq!(result.value, U256::ZERO);
1501 let data_hex = hex::encode(&result.data);
1503 assert!(
1504 data_hex.starts_with("8d80ff0a"),
1505 "Expected multiSend selector, got: {}",
1506 &data_hex[..8.min(data_hex.len())]
1507 );
1508 }
1509}