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 reqwest::Client;
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    "0xd21df8dc65880a8606f09fe0ce3df9b8869287ab0b058be05aa9e8af6330a00b";
27
28#[derive(Debug, Clone)]
29pub struct RelayClient {
30    client: Client,
31    base_url: Url,
32    chain_id: u64,
33    account: Option<BuilderAccount>,
34    contract_config: ContractConfig,
35    wallet_type: WalletType,
36}
37
38impl RelayClient {
39    /// Create a new Relay client with authentication
40    pub fn new(
41        private_key: impl Into<String>,
42        config: Option<BuilderConfig>,
43    ) -> Result<Self, RelayError> {
44        let account = BuilderAccount::new(private_key, config)?;
45        Self::builder()?.with_account(account).build()
46    }
47
48    /// Create a new Relay client builder
49    pub fn builder() -> Result<RelayClientBuilder, RelayError> {
50        RelayClientBuilder::new()
51    }
52
53    /// Create a new Relay client builder pulling settings from environment
54    pub fn default_builder() -> Result<RelayClientBuilder, RelayError> {
55        Ok(RelayClientBuilder::default())
56    }
57
58    /// Create a new Relay client from a BuilderAccount
59    pub fn from_account(account: BuilderAccount) -> Result<Self, RelayError> {
60        Self::builder()?.with_account(account).build()
61    }
62
63    pub fn address(&self) -> Option<Address> {
64        self.account.as_ref().map(|a| a.address())
65    }
66
67    /// Measure the round-trip time (RTT) to the Relay API.
68    ///
69    /// Makes a GET request to the API base URL and returns the latency.
70    ///
71    /// # Example
72    ///
73    /// ```no_run
74    /// use polyoxide_relay::RelayClient;
75    ///
76    /// # async fn example() -> Result<(), polyoxide_relay::RelayError> {
77    /// let client = RelayClient::builder()?.build()?;
78    /// let latency = client.ping().await?;
79    /// println!("API latency: {}ms", latency.as_millis());
80    /// # Ok(())
81    /// # }
82    /// ```
83    pub async fn ping(&self) -> Result<Duration, RelayError> {
84        let start = Instant::now();
85        let response = self.client.get(self.base_url.clone()).send().await?;
86        let latency = start.elapsed();
87
88        if !response.status().is_success() {
89            let text = response.text().await?;
90            return Err(RelayError::Api(format!("Ping failed: {}", text)));
91        }
92
93        Ok(latency)
94    }
95
96    pub async fn get_nonce(&self, address: Address) -> Result<u64, RelayError> {
97        let url = self.base_url.join(&format!(
98            "nonce?address={}&type={}",
99            address,
100            self.wallet_type.as_str()
101        ))?;
102        let resp = self.client.get(url).send().await?;
103
104        if !resp.status().is_success() {
105            let text = resp.text().await?;
106            return Err(RelayError::Api(format!("get_nonce failed: {}", text)));
107        }
108
109        let data = resp.json::<NonceResponse>().await?;
110        Ok(data.nonce)
111    }
112
113    pub async fn get_transaction(
114        &self,
115        transaction_id: &str,
116    ) -> Result<TransactionStatusResponse, RelayError> {
117        let url = self
118            .base_url
119            .join(&format!("transaction?id={}", transaction_id))?;
120        let resp = self.client.get(url).send().await?;
121
122        if !resp.status().is_success() {
123            let text = resp.text().await?;
124            return Err(RelayError::Api(format!("get_transaction failed: {}", text)));
125        }
126
127        resp.json::<TransactionStatusResponse>()
128            .await
129            .map_err(Into::into)
130    }
131
132    pub async fn get_deployed(&self, safe_address: Address) -> Result<bool, RelayError> {
133        #[derive(serde::Deserialize)]
134        struct DeployedResponse {
135            deployed: bool,
136        }
137        let url = self
138            .base_url
139            .join(&format!("deployed?address={}", safe_address))?;
140        let resp = self.client.get(url).send().await?;
141
142        if !resp.status().is_success() {
143            let text = resp.text().await?;
144            return Err(RelayError::Api(format!("get_deployed failed: {}", text)));
145        }
146
147        let data = resp.json::<DeployedResponse>().await?;
148        Ok(data.deployed)
149    }
150
151    fn derive_safe_address(&self, owner: Address) -> Address {
152        let salt = keccak256(owner.abi_encode());
153        let init_code_hash = hex::decode(SAFE_INIT_CODE_HASH).unwrap();
154
155        // CREATE2: keccak256(0xff ++ address ++ salt ++ keccak256(init_code))[12..]
156        let mut input = Vec::new();
157        input.push(0xff);
158        input.extend_from_slice(self.contract_config.safe_factory.as_slice());
159        input.extend_from_slice(salt.as_slice());
160        input.extend_from_slice(&init_code_hash);
161
162        let hash = keccak256(input);
163        Address::from_slice(&hash[12..])
164    }
165
166    pub fn get_expected_safe(&self) -> Result<Address, RelayError> {
167        let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
168        Ok(self.derive_safe_address(account.address()))
169    }
170
171    fn derive_proxy_wallet(&self, owner: Address) -> Result<Address, RelayError> {
172        let proxy_factory = self.contract_config.proxy_factory.ok_or_else(|| {
173            RelayError::Api("Proxy wallet not supported on this chain".to_string())
174        })?;
175
176        // Salt = keccak256(encodePacked(["address"], [address]))
177        // encodePacked for address uses the 20 bytes directly.
178        let salt = keccak256(owner.as_slice());
179
180        let init_code_hash = hex::decode(PROXY_INIT_CODE_HASH).unwrap();
181
182        // CREATE2: keccak256(0xff ++ factory ++ salt ++ init_code_hash)[12..]
183        let mut input = Vec::new();
184        input.push(0xff);
185        input.extend_from_slice(proxy_factory.as_slice());
186        input.extend_from_slice(salt.as_slice());
187        input.extend_from_slice(&init_code_hash);
188
189        let hash = keccak256(input);
190        Ok(Address::from_slice(&hash[12..]))
191    }
192
193    pub fn get_expected_proxy_wallet(&self) -> Result<Address, RelayError> {
194        let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
195        self.derive_proxy_wallet(account.address())
196    }
197
198    /// Get relay payload for PROXY wallets (returns relay address and nonce)
199    pub async fn get_relay_payload(&self, address: Address) -> Result<(Address, u64), RelayError> {
200        #[derive(serde::Deserialize)]
201        struct RelayPayload {
202            address: String,
203            #[serde(deserialize_with = "crate::types::deserialize_nonce")]
204            nonce: u64,
205        }
206
207        let url = self
208            .base_url
209            .join(&format!("relay-payload?address={}&type=PROXY", address))?;
210        let resp = self.client.get(url).send().await?;
211
212        if !resp.status().is_success() {
213            let text = resp.text().await?;
214            return Err(RelayError::Api(format!(
215                "get_relay_payload failed: {}",
216                text
217            )));
218        }
219
220        let data = resp.json::<RelayPayload>().await?;
221        let relay_address: Address = data
222            .address
223            .parse()
224            .map_err(|e| RelayError::Api(format!("Invalid relay address: {}", e)))?;
225        Ok((relay_address, data.nonce))
226    }
227
228    /// Create the proxy struct hash for signing (EIP-712 style but with specific fields)
229    #[allow(clippy::too_many_arguments)]
230    fn create_proxy_struct_hash(
231        &self,
232        from: Address,
233        to: Address,
234        data: &[u8],
235        tx_fee: U256,
236        gas_price: U256,
237        gas_limit: U256,
238        nonce: u64,
239        relay_hub: Address,
240        relay: Address,
241    ) -> [u8; 32] {
242        let mut message = Vec::new();
243
244        // "rlx:" prefix
245        message.extend_from_slice(b"rlx:");
246        // from address (20 bytes)
247        message.extend_from_slice(from.as_slice());
248        // to address (20 bytes) - This must be the ProxyFactory address
249        message.extend_from_slice(to.as_slice());
250        // data (raw bytes)
251        message.extend_from_slice(data);
252        // txFee as 32-byte big-endian
253        message.extend_from_slice(&tx_fee.to_be_bytes::<32>());
254        // gasPrice as 32-byte big-endian
255        message.extend_from_slice(&gas_price.to_be_bytes::<32>());
256        // gasLimit as 32-byte big-endian
257        message.extend_from_slice(&gas_limit.to_be_bytes::<32>());
258        // nonce as 32-byte big-endian
259        message.extend_from_slice(&U256::from(nonce).to_be_bytes::<32>());
260        // relayHub address (20 bytes)
261        message.extend_from_slice(relay_hub.as_slice());
262        // relay address (20 bytes)
263        message.extend_from_slice(relay.as_slice());
264
265        keccak256(&message).into()
266    }
267
268    /// Encode proxy transactions into calldata for the proxy wallet
269    fn encode_proxy_transaction_data(&self, txns: &[SafeTransaction]) -> Vec<u8> {
270        // ProxyTransaction struct: (uint8 typeCode, address to, uint256 value, bytes data)
271        // Function selector for proxy(ProxyTransaction[])
272        // IMPORTANT: Field order must match the ABI exactly!
273        alloy::sol! {
274            struct ProxyTransaction {
275                uint8 typeCode;
276                address to;
277                uint256 value;
278                bytes data;
279            }
280            function proxy(ProxyTransaction[] txns);
281        }
282
283        let proxy_txns: Vec<ProxyTransaction> = txns
284            .iter()
285            .map(|tx| {
286                ProxyTransaction {
287                    typeCode: 1, // 1 = Call (CallType.Call)
288                    to: tx.to,
289                    value: tx.value,
290                    data: tx.data.clone(),
291                }
292            })
293            .collect();
294
295        // Encode the function call: proxy([ProxyTransaction, ...])
296        let call = proxyCall { txns: proxy_txns };
297        call.abi_encode()
298    }
299
300    fn create_safe_multisend_transaction(&self, txns: &[SafeTransaction]) -> SafeTransaction {
301        if txns.len() == 1 {
302            return txns[0].clone();
303        }
304
305        let mut encoded_txns = Vec::new();
306        for tx in txns {
307            // Packed: [uint8 operation, address to, uint256 value, uint256 data_len, bytes data]
308            let mut packed = Vec::new();
309            packed.push(tx.operation);
310            packed.extend_from_slice(tx.to.as_slice());
311            packed.extend_from_slice(&tx.value.to_be_bytes::<32>());
312            packed.extend_from_slice(&U256::from(tx.data.len()).to_be_bytes::<32>());
313            packed.extend_from_slice(&tx.data);
314            encoded_txns.extend_from_slice(&packed);
315        }
316
317        // encoded_txns now needs to be wrapped in multiSend(bytes)
318        // selector: 8d80ff0a
319        let mut data = hex::decode("8d80ff0a").unwrap();
320
321        // Use alloy to encode `(bytes)` tuple.
322        let multisend_data = (Bytes::from(encoded_txns),).abi_encode();
323        data.extend_from_slice(&multisend_data);
324
325        SafeTransaction {
326            to: self.contract_config.safe_multisend,
327            operation: 1, // DelegateCall
328            data: data.into(),
329            value: U256::ZERO,
330        }
331    }
332
333    fn split_and_pack_sig_safe(&self, sig: alloy::primitives::Signature) -> String {
334        // Alloy's v() returns a boolean y_parity: false = 0, true = 1
335        // For Safe signatures, v must be adjusted: 0/1 + 31 = 31/32
336        let v_raw = if sig.v() { 1u8 } else { 0u8 };
337        let v = v_raw + 31;
338
339        // Pack r, s, v
340        let mut packed = Vec::new();
341        packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
342        packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
343        packed.push(v);
344
345        format!("0x{}", hex::encode(packed))
346    }
347
348    fn split_and_pack_sig_proxy(&self, sig: alloy::primitives::Signature) -> String {
349        // For Proxy signatures, use standard v value: 27 or 28
350        let v = if sig.v() { 28u8 } else { 27u8 };
351
352        // Pack r, s, v
353        let mut packed = Vec::new();
354        packed.extend_from_slice(&sig.r().to_be_bytes::<32>());
355        packed.extend_from_slice(&sig.s().to_be_bytes::<32>());
356        packed.push(v);
357
358        format!("0x{}", hex::encode(packed))
359    }
360
361    pub async fn execute(
362        &self,
363        transactions: Vec<SafeTransaction>,
364        metadata: Option<String>,
365    ) -> Result<RelayerTransactionResponse, RelayError> {
366        self.execute_with_gas(transactions, metadata, None).await
367    }
368
369    pub async fn execute_with_gas(
370        &self,
371        transactions: Vec<SafeTransaction>,
372        metadata: Option<String>,
373        gas_limit: Option<u64>,
374    ) -> Result<RelayerTransactionResponse, RelayError> {
375        match self.wallet_type {
376            WalletType::Safe => self.execute_safe(transactions, metadata).await,
377            WalletType::Proxy => self.execute_proxy(transactions, metadata, gas_limit).await,
378        }
379    }
380
381    async fn execute_safe(
382        &self,
383        transactions: Vec<SafeTransaction>,
384        metadata: Option<String>,
385    ) -> Result<RelayerTransactionResponse, RelayError> {
386        let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
387        let from_address = account.address();
388
389        let safe_address = self.derive_safe_address(from_address);
390
391        if !self.get_deployed(safe_address).await? {
392            return Err(RelayError::Api(format!(
393                "Safe {} is not deployed",
394                safe_address
395            )));
396        }
397
398        let nonce = self.get_nonce(from_address).await?;
399
400        let aggregated = self.create_safe_multisend_transaction(&transactions);
401
402        let safe_tx = SafeTx {
403            to: aggregated.to,
404            value: aggregated.value,
405            data: aggregated.data,
406            operation: aggregated.operation,
407            safeTxGas: U256::ZERO,
408            baseGas: U256::ZERO,
409            gasPrice: U256::ZERO,
410            gasToken: Address::ZERO,
411            refundReceiver: Address::ZERO,
412            nonce: U256::from(nonce),
413        };
414
415        let domain = Eip712Domain {
416            name: None,
417            version: None,
418            chain_id: Some(U256::from(self.chain_id)),
419            verifying_contract: Some(safe_address),
420            salt: None,
421        };
422
423        let struct_hash = safe_tx.eip712_signing_hash(&domain);
424        let signature = account
425            .signer()
426            .sign_message(struct_hash.as_slice())
427            .await
428            .map_err(|e| RelayError::Signer(e.to_string()))?;
429        let packed_sig = self.split_and_pack_sig_safe(signature);
430
431        #[derive(Serialize)]
432        struct SigParams {
433            #[serde(rename = "gasPrice")]
434            gas_price: String,
435            operation: String,
436            #[serde(rename = "safeTxnGas")]
437            safe_tx_gas: String,
438            #[serde(rename = "baseGas")]
439            base_gas: String,
440            #[serde(rename = "gasToken")]
441            gas_token: String,
442            #[serde(rename = "refundReceiver")]
443            refund_receiver: String,
444        }
445
446        #[derive(Serialize)]
447        struct Body {
448            #[serde(rename = "type")]
449            type_: String,
450            from: String,
451            to: String,
452            #[serde(rename = "proxyWallet")]
453            proxy_wallet: String,
454            data: String,
455            signature: String,
456            #[serde(rename = "signatureParams")]
457            signature_params: SigParams,
458            #[serde(rename = "value")]
459            value: String,
460            nonce: String,
461            #[serde(skip_serializing_if = "Option::is_none")]
462            metadata: Option<String>,
463        }
464
465        let body = Body {
466            type_: "SAFE".to_string(),
467            from: from_address.to_string(),
468            to: safe_tx.to.to_string(),
469            proxy_wallet: safe_address.to_string(),
470            data: safe_tx.data.to_string(),
471            signature: packed_sig,
472            signature_params: SigParams {
473                gas_price: "0".to_string(),
474                operation: safe_tx.operation.to_string(),
475                safe_tx_gas: "0".to_string(),
476                base_gas: "0".to_string(),
477                gas_token: Address::ZERO.to_string(),
478                refund_receiver: Address::ZERO.to_string(),
479            },
480            value: safe_tx.value.to_string(),
481            nonce: nonce.to_string(),
482            metadata,
483        };
484
485        self._post_request("submit", &body).await
486    }
487
488    async fn execute_proxy(
489        &self,
490        transactions: Vec<SafeTransaction>,
491        metadata: Option<String>,
492        gas_limit: Option<u64>,
493    ) -> Result<RelayerTransactionResponse, RelayError> {
494        let account = self.account.as_ref().ok_or(RelayError::MissingSigner)?;
495        let from_address = account.address();
496
497        let proxy_wallet = self.derive_proxy_wallet(from_address)?;
498        let relay_hub = self
499            .contract_config
500            .relay_hub
501            .ok_or_else(|| RelayError::Api("Relay hub not configured".to_string()))?;
502        let proxy_factory = self
503            .contract_config
504            .proxy_factory
505            .ok_or_else(|| RelayError::Api("Proxy factory not configured".to_string()))?;
506
507        // Get relay payload (relay address + nonce)
508        let (relay_address, nonce) = self.get_relay_payload(from_address).await?;
509
510        // Encode all transactions into proxy calldata
511        let encoded_data = self.encode_proxy_transaction_data(&transactions);
512
513        // Constants for proxy transactions
514        let tx_fee = U256::ZERO;
515        let gas_price = U256::ZERO;
516        let gas_limit = U256::from(gas_limit.unwrap_or(10_000_000u64));
517
518        // Create struct hash for signing
519        // In original code, "to" was set to proxy_wallet I think? Or proxy_factory?
520        // Let's use proxy_wallet as "to" for now (based on safe logic) but verify if it should be factory.
521        // Actually, Python client says `const to = proxyWalletFactory`.
522        // So we must use proxy_factory as "to".
523        let struct_hash = self.create_proxy_struct_hash(
524            from_address,
525            proxy_factory, // CORRECTED: Use proxy_factory
526            &encoded_data,
527            tx_fee,
528            gas_price,
529            gas_limit,
530            nonce,
531            relay_hub,
532            relay_address,
533        );
534
535        // Sign the struct hash with EIP191 prefix
536        let signature = account
537            .signer()
538            .sign_message(&struct_hash)
539            .await
540            .map_err(|e| RelayError::Signer(e.to_string()))?;
541        let packed_sig = self.split_and_pack_sig_proxy(signature);
542
543        #[derive(Serialize)]
544        struct SigParams {
545            #[serde(rename = "relayerFee")]
546            relayer_fee: String,
547            #[serde(rename = "gasLimit")]
548            gas_limit: String,
549            #[serde(rename = "gasPrice")]
550            gas_price: String,
551            #[serde(rename = "relayHub")]
552            relay_hub: String,
553            relay: 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            nonce: String,
569            #[serde(skip_serializing_if = "Option::is_none")]
570            metadata: Option<String>,
571        }
572
573        let body = Body {
574            type_: "PROXY".to_string(),
575            from: from_address.to_string(),
576            to: proxy_factory.to_string(),
577            proxy_wallet: proxy_wallet.to_string(),
578            data: format!("0x{}", hex::encode(&encoded_data)),
579            signature: packed_sig,
580            signature_params: SigParams {
581                relayer_fee: "0".to_string(),
582                gas_limit: gas_limit.to_string(),
583                gas_price: "0".to_string(),
584                relay_hub: relay_hub.to_string(),
585                relay: relay_address.to_string(),
586            },
587            nonce: nonce.to_string(),
588            metadata,
589        };
590
591        self._post_request("submit", &body).await
592    }
593
594    /// Estimate gas required for a redemption transaction.
595    ///
596    /// Returns the estimated gas limit with relayer overhead and safety buffer included.
597    /// Uses the default RPC URL configured for the current chain.
598    ///
599    /// # Arguments
600    ///
601    /// * `condition_id` - The condition ID to redeem
602    /// * `index_sets` - The index sets to redeem
603    ///
604    /// # Example
605    ///
606    /// ```no_run
607    /// use polyoxide_relay::{RelayClient, BuilderAccount, BuilderConfig, WalletType};
608    /// use alloy::primitives::{U256, hex};
609    ///
610    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
611    /// let builder_config = BuilderConfig::new(
612    ///     "key".to_string(),
613    ///     "secret".to_string(),
614    ///     None,
615    /// );
616    /// let account = BuilderAccount::new("0x...", Some(builder_config))?;
617    /// let client = RelayClient::builder()?
618    ///     .with_account(account)
619    ///     .wallet_type(WalletType::Proxy)
620    ///     .build()?;
621    ///
622    /// let condition_id = [0u8; 32];
623    /// let index_sets = vec![U256::from(1)];
624    /// let estimated_gas = client
625    ///     .estimate_redemption_gas(condition_id, index_sets)
626    ///     .await?;
627    /// println!("Estimated gas: {}", estimated_gas);
628    /// # Ok(())
629    /// # }
630    /// ```
631    pub async fn estimate_redemption_gas(
632        &self,
633        condition_id: [u8; 32],
634        index_sets: Vec<U256>,
635    ) -> Result<u64, RelayError> {
636        // 1. Define the redemption interface
637        alloy::sol! {
638            function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
639        }
640
641        // 2. Setup constants
642        let collateral =
643            Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None)
644                .map_err(|e| RelayError::Api(format!("Invalid collateral address: {}", e)))?;
645        let ctf_exchange =
646            Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None)
647                .map_err(|e| RelayError::Api(format!("Invalid CTF exchange address: {}", e)))?;
648        let parent_collection_id = [0u8; 32];
649
650        // 3. Encode the redemption calldata
651        let call = redeemPositionsCall {
652            collateral,
653            parentCollectionId: parent_collection_id.into(),
654            conditionId: condition_id.into(),
655            indexSets: index_sets,
656        };
657        let redemption_calldata = Bytes::from(call.abi_encode());
658
659        // 4. Get the proxy wallet address
660        let proxy_wallet = match self.wallet_type {
661            WalletType::Proxy => self.get_expected_proxy_wallet()?,
662            WalletType::Safe => self.get_expected_safe()?,
663        };
664
665        // 5. Create provider using the configured RPC URL
666        let provider = ProviderBuilder::new().connect_http(
667            self.contract_config
668                .rpc_url
669                .parse()
670                .map_err(|e| RelayError::Api(format!("Invalid RPC URL: {}", e)))?,
671        );
672
673        // 6. Construct a mock transaction exactly as the proxy will execute it
674        let tx = TransactionRequest::default()
675            .with_from(proxy_wallet)
676            .with_to(ctf_exchange)
677            .with_input(redemption_calldata);
678
679        // 7. Ask the Polygon node to simulate it and return the base computational cost
680        let inner_gas_used = provider
681            .estimate_gas(tx)
682            .await
683            .map_err(|e| RelayError::Api(format!("Gas estimation failed: {}", e)))?;
684
685        // 8. Add relayer execution overhead + a 20% safety buffer
686        let relayer_overhead: u64 = 50_000;
687        let safe_gas_limit = (inner_gas_used + relayer_overhead) * 120 / 100;
688
689        Ok(safe_gas_limit)
690    }
691
692    pub async fn submit_gasless_redemption(
693        &self,
694        condition_id: [u8; 32],
695        index_sets: Vec<alloy::primitives::U256>,
696    ) -> Result<RelayerTransactionResponse, RelayError> {
697        self.submit_gasless_redemption_with_gas_estimation(condition_id, index_sets, false)
698            .await
699    }
700
701    pub async fn submit_gasless_redemption_with_gas_estimation(
702        &self,
703        condition_id: [u8; 32],
704        index_sets: Vec<alloy::primitives::U256>,
705        estimate_gas: bool,
706    ) -> Result<RelayerTransactionResponse, RelayError> {
707        // 1. Define the specific interface for redemption
708        alloy::sol! {
709            function redeemPositions(address collateral, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets);
710        }
711
712        // 2. Setup Constants
713        // USDC on Polygon
714        let collateral =
715            Address::parse_checksummed("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", None).unwrap();
716        // CTF Exchange Address on Polygon
717        let ctf_exchange =
718            Address::parse_checksummed("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045", None).unwrap();
719        let parent_collection_id = [0u8; 32];
720
721        // 3. Encode the Calldata
722        let call = redeemPositionsCall {
723            collateral,
724            parentCollectionId: parent_collection_id.into(),
725            conditionId: condition_id.into(),
726            indexSets: index_sets.clone(),
727        };
728        let data = call.abi_encode();
729
730        // 4. Estimate gas if requested
731        let gas_limit = if estimate_gas {
732            Some(
733                self.estimate_redemption_gas(condition_id, index_sets.clone())
734                    .await?,
735            )
736        } else {
737            None
738        };
739
740        // 5. Construct the SafeTransaction
741        let tx = SafeTransaction {
742            to: ctf_exchange,
743            value: U256::ZERO,
744            data: data.into(),
745            operation: 0, // 0 = Call (Not DelegateCall)
746        };
747
748        // 6. Use the execute_with_gas method
749        // This handles Nonce fetching, EIP-712 Signing, and Relayer submission.
750        self.execute_with_gas(vec![tx], None, gas_limit).await
751    }
752
753    async fn _post_request<T: Serialize>(
754        &self,
755        endpoint: &str,
756        body: &T,
757    ) -> Result<RelayerTransactionResponse, RelayError> {
758        let url = self.base_url.join(endpoint)?;
759        let body_str = serde_json::to_string(body)?;
760
761        eprintln!("DEBUG POST {} path={}", url, url.path());
762        eprintln!("DEBUG body: {}", body_str);
763        tracing::debug!("POST {} with body: {}", url, body_str);
764
765        let mut headers = if let Some(account) = &self.account {
766            if let Some(config) = account.config() {
767                config
768                    .generate_relayer_v2_headers("POST", url.path(), Some(&body_str))
769                    .map_err(RelayError::Api)?
770            } else {
771                return Err(RelayError::Api(
772                    "Builder config missing - cannot authenticate request".to_string(),
773                ));
774            }
775        } else {
776            return Err(RelayError::Api(
777                "Account missing - cannot authenticate request".to_string(),
778            ));
779        };
780
781        headers.insert(
782            reqwest::header::CONTENT_TYPE,
783            reqwest::header::HeaderValue::from_static("application/json"),
784        );
785
786        let resp = self
787            .client
788            .post(url.clone())
789            .headers(headers)
790            .body(body_str.clone())
791            .send()
792            .await?;
793
794        let status = resp.status();
795        tracing::debug!("Response status for {}: {}", endpoint, status);
796
797        if !status.is_success() {
798            let text = resp.text().await?;
799            tracing::error!(
800                "Request to {} failed with status {}: {}",
801                endpoint,
802                status,
803                text
804            );
805            return Err(RelayError::Api(format!("Request failed: {}", text)));
806        }
807
808        // Get raw response text before attempting to decode
809        let response_text = resp.text().await?;
810        tracing::debug!("Raw response body from {}: {}", endpoint, response_text);
811
812        // Try to deserialize
813        serde_json::from_str(&response_text).map_err(|e| {
814            tracing::error!(
815                "Failed to decode response from {}: {}. Raw body: {}",
816                endpoint,
817                e,
818                response_text
819            );
820            RelayError::SerdeJson(e)
821        })
822    }
823}
824
825pub struct RelayClientBuilder {
826    base_url: String,
827    chain_id: u64,
828    account: Option<BuilderAccount>,
829    wallet_type: WalletType,
830}
831
832impl Default for RelayClientBuilder {
833    fn default() -> Self {
834        let relayer_url = std::env::var("RELAYER_URL")
835            .unwrap_or_else(|_| "https://relayer-v2.polymarket.com/".to_string());
836        let chain_id = std::env::var("CHAIN_ID")
837            .unwrap_or("137".to_string())
838            .parse::<u64>()
839            .unwrap_or(137);
840
841        Self::new()
842            .unwrap()
843            .url(&relayer_url)
844            .unwrap()
845            .chain_id(chain_id)
846    }
847}
848
849impl RelayClientBuilder {
850    pub fn new() -> Result<Self, RelayError> {
851        let mut base_url = Url::parse("https://relayer-v2.polymarket.com")?;
852        if !base_url.path().ends_with('/') {
853            base_url.set_path(&format!("{}/", base_url.path()));
854        }
855
856        Ok(Self {
857            base_url: base_url.to_string(),
858            chain_id: 137,
859            account: None,
860            wallet_type: WalletType::default(),
861        })
862    }
863
864    pub fn chain_id(mut self, chain_id: u64) -> Self {
865        self.chain_id = chain_id;
866        self
867    }
868
869    pub fn url(mut self, url: &str) -> Result<Self, RelayError> {
870        let mut base_url = Url::parse(url)?;
871        if !base_url.path().ends_with('/') {
872            base_url.set_path(&format!("{}/", base_url.path()));
873        }
874        self.base_url = base_url.to_string();
875        Ok(self)
876    }
877
878    pub fn with_account(mut self, account: BuilderAccount) -> Self {
879        self.account = Some(account);
880        self
881    }
882
883    pub fn wallet_type(mut self, wallet_type: WalletType) -> Self {
884        self.wallet_type = wallet_type;
885        self
886    }
887
888    pub fn build(self) -> Result<RelayClient, RelayError> {
889        let mut base_url = Url::parse(&self.base_url)?;
890        if !base_url.path().ends_with('/') {
891            base_url.set_path(&format!("{}/", base_url.path()));
892        }
893
894        let contract_config = get_contract_config(self.chain_id)
895            .ok_or_else(|| RelayError::Api(format!("Unsupported chain ID: {}", self.chain_id)))?;
896
897        Ok(RelayClient {
898            client: Client::new(),
899            base_url,
900            chain_id: self.chain_id,
901            account: self.account,
902            contract_config,
903            wallet_type: self.wallet_type,
904        })
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911
912    #[tokio::test]
913    async fn test_ping() {
914        let client = RelayClient::builder().unwrap().build().unwrap();
915        let result = client.ping().await;
916        assert!(result.is_ok(), "ping failed: {:?}", result.err());
917    }
918}