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