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::{HttpClient, HttpClientBuilder, RateLimiter};
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 self.http_client.acquire_rate_limit("/", None).await;
84 let start = Instant::now();
85 let response = self
86 .http_client
87 .client
88 .get(self.http_client.base_url.clone())
89 .send()
90 .await?;
91 let latency = start.elapsed();
92
93 if !response.status().is_success() {
94 let text = response.text().await?;
95 return Err(RelayError::Api(format!("Ping failed: {}", text)));
96 }
97
98 Ok(latency)
99 }
100
101 pub async fn get_nonce(&self, address: Address) -> Result<u64, RelayError> {
102 self.http_client.acquire_rate_limit("/nonce", None).await;
103 let url = self.http_client.base_url.join(&format!(
104 "nonce?address={}&type={}",
105 address,
106 self.wallet_type.as_str()
107 ))?;
108 let resp = self.http_client.client.get(url).send().await?;
109
110 if !resp.status().is_success() {
111 let text = resp.text().await?;
112 return Err(RelayError::Api(format!("get_nonce failed: {}", text)));
113 }
114
115 let data = resp.json::<NonceResponse>().await?;
116 Ok(data.nonce)
117 }
118
119 pub async fn get_transaction(
120 &self,
121 transaction_id: &str,
122 ) -> Result<TransactionStatusResponse, RelayError> {
123 self.http_client
124 .acquire_rate_limit("/transaction", None)
125 .await;
126 let url = self
127 .http_client
128 .base_url
129 .join(&format!("transaction?id={}", transaction_id))?;
130 let resp = self.http_client.client.get(url).send().await?;
131
132 if !resp.status().is_success() {
133 let text = resp.text().await?;
134 return Err(RelayError::Api(format!("get_transaction failed: {}", text)));
135 }
136
137 resp.json::<TransactionStatusResponse>()
138 .await
139 .map_err(Into::into)
140 }
141
142 pub async fn get_deployed(&self, safe_address: Address) -> Result<bool, RelayError> {
143 #[derive(serde::Deserialize)]
144 struct DeployedResponse {
145 deployed: bool,
146 }
147 self.http_client.acquire_rate_limit("/deployed", None).await;
148 let url = self
149 .http_client
150 .base_url
151 .join(&format!("deployed?address={}", safe_address))?;
152 let resp = self.http_client.client.get(url).send().await?;
153
154 if !resp.status().is_success() {
155 let text = resp.text().await?;
156 return Err(RelayError::Api(format!("get_deployed failed: {}", text)));
157 }
158
159 let data = resp.json::<DeployedResponse>().await?;
160 Ok(data.deployed)
161 }
162
163 fn derive_safe_address(&self, owner: Address) -> Address {
164 let salt = keccak256(owner.abi_encode());
165 let init_code_hash = hex::decode(SAFE_INIT_CODE_HASH).expect("valid hex constant");
166
167 let mut input = Vec::new();
169 input.push(0xff);
170 input.extend_from_slice(self.contract_config.safe_factory.as_slice());
171 input.extend_from_slice(salt.as_slice());
172 input.extend_from_slice(&init_code_hash);
173
174 let hash = keccak256(input);
175 Address::from_slice(&hash[12..])
176 }
177
178 pub fn get_expected_safe(&self) -> Result<Address, RelayError> {
179 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
180 Ok(self.derive_safe_address(account.address()))
181 }
182
183 fn derive_proxy_wallet(&self, owner: Address) -> Result<Address, RelayError> {
184 let proxy_factory = self.contract_config.proxy_factory.ok_or_else(|| {
185 RelayError::Api("Proxy wallet not supported on this chain".to_string())
186 })?;
187
188 let salt = keccak256(owner.as_slice());
191
192 let init_code_hash = hex::decode(PROXY_INIT_CODE_HASH).expect("valid hex constant");
193
194 let mut input = Vec::new();
196 input.push(0xff);
197 input.extend_from_slice(proxy_factory.as_slice());
198 input.extend_from_slice(salt.as_slice());
199 input.extend_from_slice(&init_code_hash);
200
201 let hash = keccak256(input);
202 Ok(Address::from_slice(&hash[12..]))
203 }
204
205 pub fn get_expected_proxy_wallet(&self) -> Result<Address, RelayError> {
206 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
207 self.derive_proxy_wallet(account.address())
208 }
209
210 pub async fn get_relay_payload(&self, address: Address) -> Result<(Address, u64), RelayError> {
212 #[derive(serde::Deserialize)]
213 struct RelayPayload {
214 address: String,
215 #[serde(deserialize_with = "crate::types::deserialize_nonce")]
216 nonce: u64,
217 }
218
219 self.http_client
220 .acquire_rate_limit("/relay-payload", None)
221 .await;
222 let url = self
223 .http_client
224 .base_url
225 .join(&format!("relay-payload?address={}&type=PROXY", address))?;
226 let resp = self.http_client.client.get(url).send().await?;
227
228 if !resp.status().is_success() {
229 let text = resp.text().await?;
230 return Err(RelayError::Api(format!(
231 "get_relay_payload failed: {}",
232 text
233 )));
234 }
235
236 let data = resp.json::<RelayPayload>().await?;
237 let relay_address: Address = data
238 .address
239 .parse()
240 .map_err(|e| RelayError::Api(format!("Invalid relay address: {}", e)))?;
241 Ok((relay_address, data.nonce))
242 }
243
244 #[allow(clippy::too_many_arguments)]
246 fn create_proxy_struct_hash(
247 &self,
248 from: Address,
249 to: Address,
250 data: &[u8],
251 tx_fee: U256,
252 gas_price: U256,
253 gas_limit: U256,
254 nonce: u64,
255 relay_hub: Address,
256 relay: Address,
257 ) -> [u8; 32] {
258 let mut message = Vec::new();
259
260 message.extend_from_slice(b"rlx:");
262 message.extend_from_slice(from.as_slice());
264 message.extend_from_slice(to.as_slice());
266 message.extend_from_slice(data);
268 message.extend_from_slice(&tx_fee.to_be_bytes::<32>());
270 message.extend_from_slice(&gas_price.to_be_bytes::<32>());
272 message.extend_from_slice(&gas_limit.to_be_bytes::<32>());
274 message.extend_from_slice(&U256::from(nonce).to_be_bytes::<32>());
276 message.extend_from_slice(relay_hub.as_slice());
278 message.extend_from_slice(relay.as_slice());
280
281 keccak256(&message).into()
282 }
283
284 fn encode_proxy_transaction_data(&self, txns: &[SafeTransaction]) -> Vec<u8> {
286 alloy::sol! {
290 struct ProxyTransaction {
291 uint8 typeCode;
292 address to;
293 uint256 value;
294 bytes data;
295 }
296 function proxy(ProxyTransaction[] txns);
297 }
298
299 let proxy_txns: Vec<ProxyTransaction> = txns
300 .iter()
301 .map(|tx| {
302 ProxyTransaction {
303 typeCode: 1, to: tx.to,
305 value: tx.value,
306 data: tx.data.clone(),
307 }
308 })
309 .collect();
310
311 let call = proxyCall { txns: proxy_txns };
313 call.abi_encode()
314 }
315
316 fn create_safe_multisend_transaction(&self, txns: &[SafeTransaction]) -> SafeTransaction {
317 if txns.len() == 1 {
318 return txns[0].clone();
319 }
320
321 let mut encoded_txns = Vec::new();
322 for tx in txns {
323 let mut packed = Vec::new();
325 packed.push(tx.operation);
326 packed.extend_from_slice(tx.to.as_slice());
327 packed.extend_from_slice(&tx.value.to_be_bytes::<32>());
328 packed.extend_from_slice(&U256::from(tx.data.len()).to_be_bytes::<32>());
329 packed.extend_from_slice(&tx.data);
330 encoded_txns.extend_from_slice(&packed);
331 }
332
333 let mut data = hex::decode("8d80ff0a").expect("valid hex constant");
336
337 let multisend_data = (Bytes::from(encoded_txns),).abi_encode();
339 data.extend_from_slice(&multisend_data);
340
341 SafeTransaction {
342 to: self.contract_config.safe_multisend,
343 operation: 1, data: data.into(),
345 value: U256::ZERO,
346 }
347 }
348
349 fn split_and_pack_sig_safe(&self, sig: alloy::primitives::Signature) -> String {
350 let v_raw = if sig.v() { 1u8 } else { 0u8 };
353 let v = v_raw + 31;
354
355 let mut packed = Vec::new();
357 packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
358 packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
359 packed.push(v);
360
361 format!("0x{}", hex::encode(packed))
362 }
363
364 fn split_and_pack_sig_proxy(&self, sig: alloy::primitives::Signature) -> String {
365 let v = if sig.v() { 28u8 } else { 27u8 };
367
368 let mut packed = Vec::new();
370 packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
371 packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
372 packed.push(v);
373
374 format!("0x{}", hex::encode(packed))
375 }
376
377 pub async fn execute(
378 &self,
379 transactions: Vec<SafeTransaction>,
380 metadata: Option<String>,
381 ) -> Result<RelayerTransactionResponse, RelayError> {
382 self.execute_with_gas(transactions, metadata, None).await
383 }
384
385 pub async fn execute_with_gas(
386 &self,
387 transactions: Vec<SafeTransaction>,
388 metadata: Option<String>,
389 gas_limit: Option<u64>,
390 ) -> Result<RelayerTransactionResponse, RelayError> {
391 if transactions.is_empty() {
392 return Err(RelayError::Api("No transactions to execute".into()));
393 }
394 match self.wallet_type {
395 WalletType::Safe => self.execute_safe(transactions, metadata).await,
396 WalletType::Proxy => self.execute_proxy(transactions, metadata, gas_limit).await,
397 }
398 }
399
400 async fn execute_safe(
401 &self,
402 transactions: Vec<SafeTransaction>,
403 metadata: Option<String>,
404 ) -> Result<RelayerTransactionResponse, RelayError> {
405 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
406 let from_address = account.address();
407
408 let safe_address = self.derive_safe_address(from_address);
409
410 if !self.get_deployed(safe_address).await? {
411 return Err(RelayError::Api(format!(
412 "Safe {} is not deployed",
413 safe_address
414 )));
415 }
416
417 let nonce = self.get_nonce(from_address).await?;
418
419 let aggregated = self.create_safe_multisend_transaction(&transactions);
420
421 let safe_tx = SafeTx {
422 to: aggregated.to,
423 value: aggregated.value,
424 data: aggregated.data,
425 operation: aggregated.operation,
426 safeTxGas: U256::ZERO,
427 baseGas: U256::ZERO,
428 gasPrice: U256::ZERO,
429 gasToken: Address::ZERO,
430 refundReceiver: Address::ZERO,
431 nonce: U256::from(nonce),
432 };
433
434 let domain = Eip712Domain {
435 name: None,
436 version: None,
437 chain_id: Some(U256::from(self.chain_id)),
438 verifying_contract: Some(safe_address),
439 salt: None,
440 };
441
442 let struct_hash = safe_tx.eip712_signing_hash(&domain);
443 let signature = account
444 .signer()
445 .sign_message(struct_hash.as_slice())
446 .await
447 .map_err(|e| RelayError::Signer(e.to_string()))?;
448 let packed_sig = self.split_and_pack_sig_safe(signature);
449
450 #[derive(Serialize)]
451 struct SigParams {
452 #[serde(rename = "gasPrice")]
453 gas_price: String,
454 operation: String,
455 #[serde(rename = "safeTxnGas")]
456 safe_tx_gas: String,
457 #[serde(rename = "baseGas")]
458 base_gas: String,
459 #[serde(rename = "gasToken")]
460 gas_token: String,
461 #[serde(rename = "refundReceiver")]
462 refund_receiver: String,
463 }
464
465 #[derive(Serialize)]
466 struct Body {
467 #[serde(rename = "type")]
468 type_: String,
469 from: String,
470 to: String,
471 #[serde(rename = "proxyWallet")]
472 proxy_wallet: String,
473 data: String,
474 signature: String,
475 #[serde(rename = "signatureParams")]
476 signature_params: SigParams,
477 #[serde(rename = "value")]
478 value: String,
479 nonce: String,
480 #[serde(skip_serializing_if = "Option::is_none")]
481 metadata: Option<String>,
482 }
483
484 let body = Body {
485 type_: "SAFE".to_string(),
486 from: from_address.to_string(),
487 to: safe_tx.to.to_string(),
488 proxy_wallet: safe_address.to_string(),
489 data: safe_tx.data.to_string(),
490 signature: packed_sig,
491 signature_params: SigParams {
492 gas_price: "0".to_string(),
493 operation: safe_tx.operation.to_string(),
494 safe_tx_gas: "0".to_string(),
495 base_gas: "0".to_string(),
496 gas_token: Address::ZERO.to_string(),
497 refund_receiver: Address::ZERO.to_string(),
498 },
499 value: safe_tx.value.to_string(),
500 nonce: nonce.to_string(),
501 metadata,
502 };
503
504 self._post_request("submit", &body).await
505 }
506
507 async fn execute_proxy(
508 &self,
509 transactions: Vec<SafeTransaction>,
510 metadata: Option<String>,
511 gas_limit: Option<u64>,
512 ) -> Result<RelayerTransactionResponse, RelayError> {
513 let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
514 let from_address = account.address();
515
516 let proxy_wallet = self.derive_proxy_wallet(from_address)?;
517 let relay_hub = self
518 .contract_config
519 .relay_hub
520 .ok_or_else(|| RelayError::Api("Relay hub not configured".to_string()))?;
521 let proxy_factory = self
522 .contract_config
523 .proxy_factory
524 .ok_or_else(|| RelayError::Api("Proxy factory not configured".to_string()))?;
525
526 let (relay_address, nonce) = self.get_relay_payload(from_address).await?;
528
529 let encoded_data = self.encode_proxy_transaction_data(&transactions);
531
532 let tx_fee = U256::ZERO;
534 let gas_price = U256::ZERO;
535 let gas_limit = U256::from(gas_limit.unwrap_or(10_000_000u64));
536
537 let struct_hash = self.create_proxy_struct_hash(
543 from_address,
544 proxy_factory, &encoded_data,
546 tx_fee,
547 gas_price,
548 gas_limit,
549 nonce,
550 relay_hub,
551 relay_address,
552 );
553
554 let signature = account
556 .signer()
557 .sign_message(&struct_hash)
558 .await
559 .map_err(|e| RelayError::Signer(e.to_string()))?;
560 let packed_sig = self.split_and_pack_sig_proxy(signature);
561
562 #[derive(Serialize)]
563 struct SigParams {
564 #[serde(rename = "relayerFee")]
565 relayer_fee: String,
566 #[serde(rename = "gasLimit")]
567 gas_limit: String,
568 #[serde(rename = "gasPrice")]
569 gas_price: String,
570 #[serde(rename = "relayHub")]
571 relay_hub: String,
572 relay: String,
573 }
574
575 #[derive(Serialize)]
576 struct Body {
577 #[serde(rename = "type")]
578 type_: String,
579 from: String,
580 to: String,
581 #[serde(rename = "proxyWallet")]
582 proxy_wallet: String,
583 data: String,
584 signature: String,
585 #[serde(rename = "signatureParams")]
586 signature_params: SigParams,
587 nonce: String,
588 #[serde(skip_serializing_if = "Option::is_none")]
589 metadata: Option<String>,
590 }
591
592 let body = Body {
593 type_: "PROXY".to_string(),
594 from: from_address.to_string(),
595 to: proxy_factory.to_string(),
596 proxy_wallet: proxy_wallet.to_string(),
597 data: format!("0x{}", hex::encode(&encoded_data)),
598 signature: packed_sig,
599 signature_params: SigParams {
600 relayer_fee: "0".to_string(),
601 gas_limit: gas_limit.to_string(),
602 gas_price: "0".to_string(),
603 relay_hub: relay_hub.to_string(),
604 relay: relay_address.to_string(),
605 },
606 nonce: nonce.to_string(),
607 metadata,
608 };
609
610 self._post_request("submit", &body).await
611 }
612
613 pub async fn estimate_redemption_gas(
651 &self,
652 condition_id: [u8; 32],
653 index_sets: Vec<U256>,
654 ) -> Result<u64, RelayError> {
655 alloy::sol! {
657 function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
658 }
659
660 let collateral =
662 Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None)
663 .map_err(|e| RelayError::Api(format!("Invalid collateral address: {}", e)))?;
664 let ctf_exchange =
665 Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None)
666 .map_err(|e| RelayError::Api(format!("Invalid CTF exchange address: {}", e)))?;
667 let parent_collection_id = [0u8; 32];
668
669 let call = redeemPositionsCall {
671 collateral,
672 parentCollectionId: parent_collection_id.into(),
673 conditionId: condition_id.into(),
674 indexSets: index_sets,
675 };
676 let redemption_calldata = Bytes::from(call.abi_encode());
677
678 let proxy_wallet = match self.wallet_type {
680 WalletType::Proxy => self.get_expected_proxy_wallet()?,
681 WalletType::Safe => self.get_expected_safe()?,
682 };
683
684 let provider = ProviderBuilder::new().connect_http(
686 self.contract_config
687 .rpc_url
688 .parse()
689 .map_err(|e| RelayError::Api(format!("Invalid RPC URL: {}", e)))?,
690 );
691
692 let tx = TransactionRequest::default()
694 .with_from(proxy_wallet)
695 .with_to(ctf_exchange)
696 .with_input(redemption_calldata);
697
698 let inner_gas_used = provider
700 .estimate_gas(tx)
701 .await
702 .map_err(|e| RelayError::Api(format!("Gas estimation failed: {}", e)))?;
703
704 let relayer_overhead: u64 = 50_000;
706 let safe_gas_limit = (inner_gas_used + relayer_overhead) * 120 / 100;
707
708 Ok(safe_gas_limit)
709 }
710
711 pub async fn submit_gasless_redemption(
712 &self,
713 condition_id: [u8; 32],
714 index_sets: Vec<alloy::primitives::U256>,
715 ) -> Result<RelayerTransactionResponse, RelayError> {
716 self.submit_gasless_redemption_with_gas_estimation(condition_id, index_sets, false)
717 .await
718 }
719
720 pub async fn submit_gasless_redemption_with_gas_estimation(
721 &self,
722 condition_id: [u8; 32],
723 index_sets: Vec<alloy::primitives::U256>,
724 estimate_gas: bool,
725 ) -> Result<RelayerTransactionResponse, RelayError> {
726 alloy::sol! {
728 function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
729 }
730
731 let collateral =
734 Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None)
735 .map_err(|e| RelayError::Api(format!("Invalid address: {}", e)))?;
736 let ctf_exchange =
738 Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None)
739 .map_err(|e| RelayError::Api(format!("Invalid address: {}", e)))?;
740 let parent_collection_id = [0u8; 32];
741
742 let call = redeemPositionsCall {
744 collateral,
745 parentCollectionId: parent_collection_id.into(),
746 conditionId: condition_id.into(),
747 indexSets: index_sets.clone(),
748 };
749 let data = call.abi_encode();
750
751 let gas_limit = if estimate_gas {
753 Some(
754 self.estimate_redemption_gas(condition_id, index_sets.clone())
755 .await?,
756 )
757 } else {
758 None
759 };
760
761 let tx = SafeTransaction {
763 to: ctf_exchange,
764 value: U256::ZERO,
765 data: data.into(),
766 operation: 0, };
768
769 self.execute_with_gas(vec![tx], None, gas_limit).await
772 }
773
774 async fn _post_request<T: Serialize>(
775 &self,
776 endpoint: &str,
777 body: &T,
778 ) -> Result<RelayerTransactionResponse, RelayError> {
779 self.http_client
780 .acquire_rate_limit(&format!("/{}", endpoint), Some(&reqwest::Method::POST))
781 .await;
782
783 let url = self.http_client.base_url.join(endpoint)?;
784 let body_str = serde_json::to_string(body)?;
785
786 let mut headers = if let Some(account) = &self.account {
787 if let Some(config) = account.config() {
788 config
789 .generate_relayer_v2_headers("POST", url.path(), Some(&body_str))
790 .map_err(RelayError::Api)?
791 } else {
792 return Err(RelayError::Api(
793 "Builder config missing - cannot authenticate request".to_string(),
794 ));
795 }
796 } else {
797 return Err(RelayError::Api(
798 "Account missing - cannot authenticate request".to_string(),
799 ));
800 };
801
802 headers.insert(
803 reqwest::header::CONTENT_TYPE,
804 reqwest::header::HeaderValue::from_static("application/json"),
805 );
806
807 let resp = self
808 .http_client
809 .client
810 .post(url.clone())
811 .headers(headers)
812 .body(body_str.clone())
813 .send()
814 .await?;
815
816 let status = resp.status();
817 tracing::debug!("Response status for {}: {}", endpoint, status);
818
819 if status == reqwest::StatusCode::TOO_MANY_REQUESTS {
820 return Err(RelayError::RateLimit);
821 }
822
823 if !status.is_success() {
824 let text = resp.text().await?;
825 tracing::error!(
826 "Request to {} failed with status {}: {}",
827 endpoint,
828 status,
829 text
830 );
831 return Err(RelayError::Api(format!("Request failed: {}", text)));
832 }
833
834 let response_text = resp.text().await?;
835
836 serde_json::from_str(&response_text).map_err(|e| {
838 tracing::error!(
839 "Failed to decode response from {}: {}. Raw body: {}",
840 endpoint,
841 e,
842 response_text
843 );
844 RelayError::SerdeJson(e)
845 })
846 }
847}
848
849pub struct RelayClientBuilder {
850 base_url: String,
851 chain_id: u64,
852 account: Option<BuilderAccount>,
853 wallet_type: WalletType,
854}
855
856impl Default for RelayClientBuilder {
857 fn default() -> Self {
858 let relayer_url = std::env::var("RELAYER_URL")
859 .unwrap_or_else(|_| "https://relayer-v2.polymarket.com/".to_string());
860 let chain_id = std::env::var("CHAIN_ID")
861 .unwrap_or("137".to_string())
862 .parse::<u64>()
863 .unwrap_or(137);
864
865 Self::new()
866 .expect("default URL is valid")
867 .url(&relayer_url)
868 .expect("default URL is valid")
869 .chain_id(chain_id)
870 }
871}
872
873impl RelayClientBuilder {
874 pub fn new() -> Result<Self, RelayError> {
875 let mut base_url = Url::parse("https://relayer-v2.polymarket.com")?;
876 if !base_url.path().ends_with('/') {
877 base_url.set_path(&format!("{}/", base_url.path()));
878 }
879
880 Ok(Self {
881 base_url: base_url.to_string(),
882 chain_id: 137,
883 account: None,
884 wallet_type: WalletType::default(),
885 })
886 }
887
888 pub fn chain_id(mut self, chain_id: u64) -> Self {
889 self.chain_id = chain_id;
890 self
891 }
892
893 pub fn url(mut self, url: &str) -> Result<Self, RelayError> {
894 let mut base_url = Url::parse(url)?;
895 if !base_url.path().ends_with('/') {
896 base_url.set_path(&format!("{}/", base_url.path()));
897 }
898 self.base_url = base_url.to_string();
899 Ok(self)
900 }
901
902 pub fn with_account(mut self, account: BuilderAccount) -> Self {
903 self.account = Some(account);
904 self
905 }
906
907 pub fn wallet_type(mut self, wallet_type: WalletType) -> Self {
908 self.wallet_type = wallet_type;
909 self
910 }
911
912 pub fn build(self) -> Result<RelayClient, RelayError> {
913 let mut base_url = Url::parse(&self.base_url)?;
914 if !base_url.path().ends_with('/') {
915 base_url.set_path(&format!("{}/", base_url.path()));
916 }
917
918 let contract_config = get_contract_config(self.chain_id)
919 .ok_or_else(|| RelayError::Api(format!("Unsupported chain ID: {}", self.chain_id)))?;
920
921 let http_client = HttpClientBuilder::new(base_url.as_str())
922 .with_rate_limiter(RateLimiter::relay_default())
923 .build()?;
924
925 Ok(RelayClient {
926 http_client,
927 chain_id: self.chain_id,
928 account: self.account,
929 contract_config,
930 wallet_type: self.wallet_type,
931 })
932 }
933}
934
935#[cfg(test)]
936mod tests {
937 use super::*;
938
939 #[tokio::test]
940 async fn test_ping() {
941 let client = RelayClient::builder().unwrap().build().unwrap();
942 let result = client.ping().await;
943 assert!(result.is_ok(), "ping failed: {:?}", result.err());
944 }
945
946 #[test]
947 fn test_hex_constants_are_valid() {
948 hex::decode(SAFE_INIT_CODE_HASH).expect("SAFE_INIT_CODE_HASH should be valid hex");
949 hex::decode(PROXY_INIT_CODE_HASH).expect("PROXY_INIT_CODE_HASH should be valid hex");
950 }
951
952 #[test]
953 fn test_contract_config_polygon_mainnet() {
954 let config = get_contract_config(137);
955 assert!(config.is_some(), "should return config for Polygon mainnet");
956 let config = config.unwrap();
957 assert!(config.proxy_factory.is_some());
958 assert!(config.relay_hub.is_some());
959 }
960
961 #[test]
962 fn test_contract_config_amoy_testnet() {
963 let config = get_contract_config(80002);
964 assert!(config.is_some(), "should return config for Amoy testnet");
965 let config = config.unwrap();
966 assert!(
967 config.proxy_factory.is_none(),
968 "proxy not supported on Amoy"
969 );
970 assert!(
971 config.relay_hub.is_none(),
972 "relay hub not supported on Amoy"
973 );
974 }
975
976 #[test]
977 fn test_contract_config_unknown_chain() {
978 assert!(get_contract_config(999).is_none());
979 }
980
981 #[test]
982 fn test_relay_client_builder_default() {
983 let builder = RelayClientBuilder::default();
984 assert_eq!(builder.chain_id, 137);
985 }
986
987 #[test]
990 fn test_builder_unsupported_chain() {
991 let result = RelayClient::builder().unwrap().chain_id(999).build();
992 assert!(result.is_err());
993 let err_msg = format!("{}", result.unwrap_err());
994 assert!(
995 err_msg.contains("Unsupported chain ID"),
996 "Expected unsupported chain error, got: {err_msg}"
997 );
998 }
999
1000 #[test]
1001 fn test_builder_with_wallet_type() {
1002 let client = RelayClient::builder()
1003 .unwrap()
1004 .wallet_type(WalletType::Proxy)
1005 .build()
1006 .unwrap();
1007 assert_eq!(client.wallet_type, WalletType::Proxy);
1008 }
1009
1010 #[test]
1011 fn test_builder_no_account_address_is_none() {
1012 let client = RelayClient::builder().unwrap().build().unwrap();
1013 assert!(client.address().is_none());
1014 }
1015
1016 const TEST_KEY: &str = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1020
1021 fn test_client_with_account() -> RelayClient {
1022 let account = crate::BuilderAccount::new(TEST_KEY, None).unwrap();
1023 RelayClient::builder()
1024 .unwrap()
1025 .with_account(account)
1026 .build()
1027 .unwrap()
1028 }
1029
1030 #[test]
1031 fn test_derive_safe_address_deterministic() {
1032 let client = test_client_with_account();
1033 let addr1 = client.get_expected_safe().unwrap();
1034 let addr2 = client.get_expected_safe().unwrap();
1035 assert_eq!(addr1, addr2);
1036 }
1037
1038 #[test]
1039 fn test_derive_safe_address_nonzero() {
1040 let client = test_client_with_account();
1041 let addr = client.get_expected_safe().unwrap();
1042 assert_ne!(addr, Address::ZERO);
1043 }
1044
1045 #[test]
1046 fn test_derive_proxy_wallet_deterministic() {
1047 let client = test_client_with_account();
1048 let addr1 = client.get_expected_proxy_wallet().unwrap();
1049 let addr2 = client.get_expected_proxy_wallet().unwrap();
1050 assert_eq!(addr1, addr2);
1051 }
1052
1053 #[test]
1054 fn test_safe_and_proxy_addresses_differ() {
1055 let client = test_client_with_account();
1056 let safe = client.get_expected_safe().unwrap();
1057 let proxy = client.get_expected_proxy_wallet().unwrap();
1058 assert_ne!(safe, proxy);
1059 }
1060
1061 #[test]
1062 fn test_derive_proxy_wallet_no_account() {
1063 let client = RelayClient::builder().unwrap().build().unwrap();
1064 let result = client.get_expected_proxy_wallet();
1065 assert!(result.is_err());
1066 }
1067
1068 #[test]
1069 fn test_derive_proxy_wallet_amoy_unsupported() {
1070 let account = crate::BuilderAccount::new(TEST_KEY, None).unwrap();
1071 let client = RelayClient::builder()
1072 .unwrap()
1073 .chain_id(80002)
1074 .with_account(account)
1075 .build()
1076 .unwrap();
1077 let result = client.get_expected_proxy_wallet();
1079 assert!(result.is_err());
1080 }
1081
1082 #[test]
1085 fn test_split_and_pack_sig_safe_format() {
1086 let client = test_client_with_account();
1087 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1089 alloy::primitives::B256::from([1u8; 32]),
1090 alloy::primitives::B256::from([2u8; 32]),
1091 false, );
1093 let packed = client.split_and_pack_sig_safe(sig);
1094 assert!(packed.starts_with("0x"));
1095 assert_eq!(packed.len(), 132);
1097 assert!(packed.ends_with("1f"), "expected v=31(0x1f), got: {packed}");
1099 }
1100
1101 #[test]
1102 fn test_split_and_pack_sig_safe_v_true() {
1103 let client = test_client_with_account();
1104 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1105 alloy::primitives::B256::from([0xAA; 32]),
1106 alloy::primitives::B256::from([0xBB; 32]),
1107 true, );
1109 let packed = client.split_and_pack_sig_safe(sig);
1110 assert!(packed.ends_with("20"), "expected v=32(0x20), got: {packed}");
1112 }
1113
1114 #[test]
1115 fn test_split_and_pack_sig_proxy_format() {
1116 let client = test_client_with_account();
1117 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1118 alloy::primitives::B256::from([1u8; 32]),
1119 alloy::primitives::B256::from([2u8; 32]),
1120 false, );
1122 let packed = client.split_and_pack_sig_proxy(sig);
1123 assert!(packed.starts_with("0x"));
1124 assert_eq!(packed.len(), 132);
1125 assert!(packed.ends_with("1b"), "expected v=27(0x1b), got: {packed}");
1127 }
1128
1129 #[test]
1130 fn test_split_and_pack_sig_proxy_v_true() {
1131 let client = test_client_with_account();
1132 let sig = alloy::primitives::Signature::from_scalars_and_parity(
1133 alloy::primitives::B256::from([0xAA; 32]),
1134 alloy::primitives::B256::from([0xBB; 32]),
1135 true, );
1137 let packed = client.split_and_pack_sig_proxy(sig);
1138 assert!(packed.ends_with("1c"), "expected v=28(0x1c), got: {packed}");
1140 }
1141
1142 #[test]
1145 fn test_encode_proxy_transaction_data_single() {
1146 let client = test_client_with_account();
1147 let txns = vec![SafeTransaction {
1148 to: Address::ZERO,
1149 operation: 0,
1150 data: alloy::primitives::Bytes::from(vec![0xde, 0xad]),
1151 value: U256::ZERO,
1152 }];
1153 let encoded = client.encode_proxy_transaction_data(&txns);
1154 assert!(
1156 encoded.len() >= 4,
1157 "encoded data too short: {} bytes",
1158 encoded.len()
1159 );
1160 }
1161
1162 #[test]
1163 fn test_encode_proxy_transaction_data_multiple() {
1164 let client = test_client_with_account();
1165 let txns = vec![
1166 SafeTransaction {
1167 to: Address::ZERO,
1168 operation: 0,
1169 data: alloy::primitives::Bytes::from(vec![0x01]),
1170 value: U256::ZERO,
1171 },
1172 SafeTransaction {
1173 to: Address::ZERO,
1174 operation: 0,
1175 data: alloy::primitives::Bytes::from(vec![0x02]),
1176 value: U256::from(100),
1177 },
1178 ];
1179 let encoded = client.encode_proxy_transaction_data(&txns);
1180 assert!(encoded.len() >= 4);
1181 let single = client.encode_proxy_transaction_data(&txns[..1]);
1183 assert!(encoded.len() > single.len());
1184 }
1185
1186 #[test]
1187 fn test_encode_proxy_transaction_data_empty() {
1188 let client = test_client_with_account();
1189 let encoded = client.encode_proxy_transaction_data(&[]);
1190 assert!(encoded.len() >= 4);
1192 }
1193
1194 #[test]
1197 fn test_multisend_single_returns_same() {
1198 let client = test_client_with_account();
1199 let tx = SafeTransaction {
1200 to: Address::from([0x42; 20]),
1201 operation: 0,
1202 data: alloy::primitives::Bytes::from(vec![0xAB]),
1203 value: U256::from(99),
1204 };
1205 let result = client.create_safe_multisend_transaction(std::slice::from_ref(&tx));
1206 assert_eq!(result.to, tx.to);
1207 assert_eq!(result.value, tx.value);
1208 assert_eq!(result.data, tx.data);
1209 assert_eq!(result.operation, tx.operation);
1210 }
1211
1212 #[test]
1213 fn test_multisend_multiple_uses_delegate_call() {
1214 let client = test_client_with_account();
1215 let txns = vec![
1216 SafeTransaction {
1217 to: Address::from([0x01; 20]),
1218 operation: 0,
1219 data: alloy::primitives::Bytes::from(vec![0x01]),
1220 value: U256::ZERO,
1221 },
1222 SafeTransaction {
1223 to: Address::from([0x02; 20]),
1224 operation: 0,
1225 data: alloy::primitives::Bytes::from(vec![0x02]),
1226 value: U256::ZERO,
1227 },
1228 ];
1229 let result = client.create_safe_multisend_transaction(&txns);
1230 assert_eq!(result.operation, 1);
1232 assert_eq!(result.to, client.contract_config.safe_multisend);
1233 assert_eq!(result.value, U256::ZERO);
1234 let data_hex = hex::encode(&result.data);
1236 assert!(
1237 data_hex.starts_with("8d80ff0a"),
1238 "Expected multiSend selector, got: {}",
1239 &data_hex[..8.min(data_hex.len())]
1240 );
1241 }
1242}