Skip to main content

polyoxide_relay/
client.rs

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