Skip to main content

polyoxide_relay/
client.rs

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