Skip to main content

polyoxide_relay/
client.rs

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