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