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