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