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