Skip to main content

fynd_client/
client.rs

1use std::{collections::HashMap, time::Duration};
2
3use alloy::{
4    consensus::{TxEip1559, TypedTransaction},
5    eips::eip2930::AccessList,
6    network::Ethereum,
7    primitives::{Address, Bytes as AlloyBytes, TxKind, B256},
8    providers::{Provider, ProviderBuilder, RootProvider},
9    rpc::types::{
10        state::{AccountOverride, StateOverride},
11        TransactionRequest,
12    },
13};
14use bytes::Bytes;
15use num_bigint::BigUint;
16use reqwest::Client as HttpClient;
17
18use crate::{
19    error::FyndError,
20    mapping,
21    signing::{
22        compute_settled_amount, ApprovalPayload, ExecutionReceipt, FyndPayload, MinedTx,
23        SettledOrder, SignedApproval, SignedSwap, SwapPayload, TxReceipt,
24    },
25    types::{
26        BackendKind, BatchQuoteParams, HealthStatus, InstanceInfo, Quote, QuoteParams,
27        UserTransferType,
28    },
29};
30// ============================================================================
31// RETRY CONFIG
32// ============================================================================
33
34/// Controls how [`FyndClient::quote`] retries transient failures.
35///
36/// Retries use exponential back-off: each attempt doubles the delay, capped at
37/// [`max_backoff`](Self::max_backoff). Only errors where
38/// [`FyndError::is_retryable`](crate::FyndError::is_retryable) returns `true` are retried.
39#[derive(Clone)]
40pub struct RetryConfig {
41    max_attempts: u32,
42    initial_backoff: Duration,
43    max_backoff: Duration,
44}
45
46impl RetryConfig {
47    /// Create a custom retry configuration.
48    ///
49    /// - `max_attempts`: total attempts including the first try.
50    /// - `initial_backoff`: sleep duration before the second attempt.
51    /// - `max_backoff`: upper bound on any single sleep duration.
52    pub fn new(max_attempts: u32, initial_backoff: Duration, max_backoff: Duration) -> Self {
53        Self { max_attempts, initial_backoff, max_backoff }
54    }
55
56    /// Maximum number of total attempts (default: 3).
57    pub fn max_attempts(&self) -> u32 {
58        self.max_attempts
59    }
60
61    /// Sleep duration before the first retry (default: 100 ms).
62    pub fn initial_backoff(&self) -> Duration {
63        self.initial_backoff
64    }
65
66    /// Upper bound on any single sleep duration (default: 2 s).
67    pub fn max_backoff(&self) -> Duration {
68        self.max_backoff
69    }
70}
71
72impl Default for RetryConfig {
73    fn default() -> Self {
74        Self {
75            max_attempts: 3,
76            initial_backoff: Duration::from_millis(100),
77            max_backoff: Duration::from_secs(2),
78        }
79    }
80}
81
82// ============================================================================
83// SIGNING HINTS
84// ============================================================================
85
86/// Optional hints to override auto-resolved transaction parameters.
87///
88/// All fields default to `None` / `false`. Unset fields are resolved automatically from the
89/// RPC node during [`FyndClient::swap_payload`].
90///
91/// Build via the setter methods; all options are unset by default.
92#[derive(Clone, Default)]
93pub struct SigningHints {
94    sender: Option<Address>,
95    nonce: Option<u64>,
96    max_fee_per_gas: Option<u128>,
97    max_priority_fee_per_gas: Option<u128>,
98    gas_limit: Option<u64>,
99    simulate: bool,
100}
101
102impl SigningHints {
103    /// Override the sender address. If not set, falls back to the address configured on the
104    /// client via [`FyndClientBuilder::with_sender`].
105    pub fn with_sender(mut self, sender: Address) -> Self {
106        self.sender = Some(sender);
107        self
108    }
109
110    /// Override the transaction nonce. If not set, fetched via `eth_getTransactionCount`.
111    pub fn with_nonce(mut self, nonce: u64) -> Self {
112        self.nonce = Some(nonce);
113        self
114    }
115
116    /// Override `maxFeePerGas` (wei). If not set, estimated via `eth_feeHistory`.
117    pub fn with_max_fee_per_gas(mut self, max_fee_per_gas: u128) -> Self {
118        self.max_fee_per_gas = Some(max_fee_per_gas);
119        self
120    }
121
122    /// Override `maxPriorityFeePerGas` (wei). If not set, estimated alongside `max_fee_per_gas`.
123    pub fn with_max_priority_fee_per_gas(mut self, max_priority_fee_per_gas: u128) -> Self {
124        self.max_priority_fee_per_gas = Some(max_priority_fee_per_gas);
125        self
126    }
127
128    /// Override the gas limit. If not set, estimated via `eth_estimateGas` against the
129    /// current chain state. Set explicitly to opt out (e.g. use `quote.gas_estimate()`
130    /// as a pre-buffered fallback).
131    pub fn with_gas_limit(mut self, gas_limit: u64) -> Self {
132        self.gas_limit = Some(gas_limit);
133        self
134    }
135
136    /// When `true`, simulate the transaction via `eth_call` before returning. A simulation
137    /// failure results in [`FyndError::SimulationFailed`].
138    pub fn with_simulate(mut self, simulate: bool) -> Self {
139        self.simulate = simulate;
140        self
141    }
142
143    /// The configured sender override, or `None` to fall back to the client default.
144    pub fn sender(&self) -> Option<Address> {
145        self.sender
146    }
147
148    /// The configured nonce override, or `None` to fetch from the RPC node.
149    pub fn nonce(&self) -> Option<u64> {
150        self.nonce
151    }
152
153    /// The configured `maxFeePerGas` override (wei), or `None` to estimate.
154    pub fn max_fee_per_gas(&self) -> Option<u128> {
155        self.max_fee_per_gas
156    }
157
158    /// The configured `maxPriorityFeePerGas` override (wei), or `None` to estimate.
159    pub fn max_priority_fee_per_gas(&self) -> Option<u128> {
160        self.max_priority_fee_per_gas
161    }
162
163    /// The configured gas limit override, or `None` to use the quote's estimate.
164    pub fn gas_limit(&self) -> Option<u64> {
165        self.gas_limit
166    }
167
168    /// Whether to simulate the transaction via `eth_call` before returning.
169    pub fn simulate(&self) -> bool {
170        self.simulate
171    }
172}
173
174// ============================================================================
175// STORAGE OVERRIDES
176// ============================================================================
177
178/// Per-account EVM storage slot overrides for dry-run simulations.
179///
180/// Maps 20-byte contract addresses to a set of 32-byte slot → value pairs. Passed via
181/// [`ExecutionOptions::storage_overrides`] to override on-chain state during a
182/// [`FyndClient::execute_swap`] dry run.
183///
184/// # Example
185///
186/// ```rust
187/// use fynd_client::StorageOverrides;
188/// use bytes::Bytes;
189///
190/// let mut overrides = StorageOverrides::default();
191/// let contract = Bytes::copy_from_slice(&[0xAA; 20]);
192/// let slot    = Bytes::copy_from_slice(&[0x00; 32]);
193/// let value   = Bytes::copy_from_slice(&[0x01; 32]);
194/// overrides.insert(contract, slot, value);
195/// ```
196#[derive(Clone, Default)]
197pub struct StorageOverrides {
198    /// address (20 bytes) → { slot (32 bytes) → value (32 bytes) }
199    slots: HashMap<Bytes, HashMap<Bytes, Bytes>>,
200    /// address (20 bytes) → native balance in wei
201    balances: HashMap<Bytes, BigUint>,
202}
203
204impl StorageOverrides {
205    /// Add a storage slot override for a contract.
206    ///
207    /// - `address`: 20-byte contract address.
208    /// - `slot`: 32-byte storage slot key.
209    /// - `value`: 32-byte replacement value.
210    pub fn insert(&mut self, address: Bytes, slot: Bytes, value: Bytes) {
211        self.slots
212            .entry(address)
213            .or_default()
214            .insert(slot, value);
215    }
216
217    /// Override the native (ETH) balance of an account for dry-run simulation.
218    ///
219    /// Useful when simulating transactions from a synthetic sender that has no real ETH —
220    /// many nodes (e.g. reth) reject `eth_estimateGas` if the sender cannot afford
221    /// `gas_limit * max_fee_per_gas`.
222    pub fn set_native_balance(&mut self, address: Bytes, wei: BigUint) {
223        self.balances.insert(address, wei);
224    }
225
226    /// Merge all slot and balance overrides from `other` into `self`. Balances in `other`
227    /// take precedence on conflict.
228    pub fn merge(&mut self, other: StorageOverrides) {
229        for (address, slots) in other.slots {
230            let entry = self.slots.entry(address).or_default();
231            entry.extend(slots);
232        }
233        self.balances.extend(other.balances);
234    }
235}
236
237fn storage_overrides_to_alloy(so: &StorageOverrides) -> Result<StateOverride, FyndError> {
238    let mut result = StateOverride::default();
239    for (addr_bytes, slot_map) in &so.slots {
240        let addr = mapping::bytes_to_alloy_address(addr_bytes)?;
241        let state_diff = slot_map
242            .iter()
243            .map(|(slot, val)| Ok((bytes_to_b256(slot)?, bytes_to_b256(val)?)))
244            .collect::<Result<alloy::primitives::map::B256HashMap<B256>, FyndError>>()?;
245        result.insert(addr, AccountOverride { state_diff: Some(state_diff), ..Default::default() });
246    }
247    for (addr_bytes, wei) in &so.balances {
248        let addr = mapping::bytes_to_alloy_address(addr_bytes)?;
249        let entry = result
250            .entry(addr)
251            .or_insert_with(AccountOverride::default);
252        entry.balance = Some(mapping::biguint_to_u256(wei));
253    }
254    Ok(result)
255}
256
257fn bytes_to_b256(b: &Bytes) -> Result<B256, FyndError> {
258    if b.len() != 32 {
259        return Err(FyndError::Protocol(format!("expected 32-byte slot, got {} bytes", b.len())));
260    }
261    let arr: [u8; 32] = b
262        .as_ref()
263        .try_into()
264        .expect("length checked above");
265    Ok(B256::from(arr))
266}
267
268// ============================================================================
269// EXECUTION OPTIONS
270// ============================================================================
271
272/// Options controlling the behaviour of [`FyndClient::execute_swap`].
273#[derive(Clone)]
274pub struct ExecutionOptions {
275    /// When `true`, simulate the transaction via `eth_call` and `estimate_gas` instead of
276    /// broadcasting it. The returned [`ExecutionReceipt`] resolves immediately with the
277    /// simulated settled amount (decoded from the call return data) and the estimated gas cost.
278    /// No transaction is submitted to the network.
279    pub dry_run: bool,
280    /// Storage slot overrides to apply during dry-run simulation. Ignored when `dry_run` is
281    /// `false`.
282    pub storage_overrides: Option<StorageOverrides>,
283    /// When `true` (default), a reverted transaction triggers a `debug_traceTransaction` call
284    /// to retrieve the revert reason, falling back to `eth_call` if the node does not support
285    /// the debug API. Set to `false` to skip the extra round-trip and return a bare
286    /// [`FyndError::TransactionReverted`] with only the transaction hash.
287    pub fetch_revert_reason: bool,
288}
289
290impl Default for ExecutionOptions {
291    fn default() -> Self {
292        Self { dry_run: false, storage_overrides: None, fetch_revert_reason: true }
293    }
294}
295
296// ============================================================================
297// APPROVAL PARAMS
298// ============================================================================
299
300/// Controls whether [`FyndClient::approval`] checks the current on-chain allowance before
301/// building an approval transaction.
302#[derive(Clone)]
303pub enum AllowanceCheck {
304    /// Always build the approval payload — do not read the current allowance.
305    Skip,
306    /// Return `None` (no approval needed) if the current allowance is ≥ the given threshold.
307    ///
308    /// Pass the minimum amount required for the operation. For standard ERC-20 flows this is the
309    /// same as the approve amount; for Permit2 it can be the swap amount while the actual
310    /// approval is for a larger value (e.g. `max_uint160`) to avoid re-approving every swap.
311    AtLeast(BigUint),
312}
313
314/// Parameters for [`FyndClient::approval`].
315#[derive(Clone)]
316pub struct ApprovalParams {
317    token: bytes::Bytes,
318    amount: BigUint,
319    allowance_check: AllowanceCheck,
320    transfer_type: UserTransferType,
321}
322
323impl ApprovalParams {
324    /// Create approval parameters for the given token and amount.
325    ///
326    /// Defaults to a standard ERC-20 approval against the router contract.
327    /// Use [`with_transfer_type`](Self::with_transfer_type) to approve the Permit2 contract
328    /// instead.
329    pub fn new(
330        token: bytes::Bytes,
331        amount: num_bigint::BigUint,
332        allowance_check: AllowanceCheck,
333    ) -> Self {
334        Self { token, amount, allowance_check, transfer_type: UserTransferType::TransferFrom }
335    }
336
337    /// Override the transfer type (and thus the spender contract).
338    ///
339    /// `UserTransferType::TransferFrom` → router (default).
340    /// `UserTransferType::TransferFromPermit2` → Permit2.
341    /// `UserTransferType::UseVaultsFunds` → [`FyndClient::approval`] returns `None` immediately.
342    pub fn with_transfer_type(mut self, transfer_type: UserTransferType) -> Self {
343        self.transfer_type = transfer_type;
344        self
345    }
346}
347
348// ============================================================================
349// ERC-20 ABI
350// ============================================================================
351
352mod erc20 {
353    use alloy::sol;
354
355    sol! {
356        function approve(address spender, uint256 amount) returns (bool);
357        function allowance(address owner, address spender) returns (uint256);
358    }
359}
360
361// ============================================================================
362// CLIENT BUILDER
363// ============================================================================
364
365/// Builder for [`FyndClient`].
366///
367/// Call [`FyndClientBuilder::new`] with the Fynd base URL, configure optional settings, then
368/// call [`build`](Self::build) or [`build_quote_only`](Self::build_quote_only).
369///
370/// `build` validates the RPC URL and fetches `chain_id` from the Ethereum node. It does **not**
371/// connect to the Fynd API.
372pub struct FyndClientBuilder {
373    base_url: String,
374    timeout: Duration,
375    retry: RetryConfig,
376    rpc_url: Option<String>,
377    submit_url: Option<String>,
378    sender: Option<Address>,
379}
380
381impl FyndClientBuilder {
382    /// Create a new builder.
383    ///
384    /// - `base_url`: Base URL of the Fynd RPC server (e.g. `"https://rpc.fynd.exchange"`). Must use
385    ///   `http` or `https` scheme.
386    ///
387    /// Call [`with_rpc_url`](Self::with_rpc_url) before [`build`](Self::build) to enable
388    /// on-chain operations (`swap_payload`, `execute_swap`, `approval`). For quote-only use,
389    /// call [`build_quote_only`](Self::build_quote_only) directly — no RPC URL required.
390    pub fn new(base_url: impl Into<String>) -> Self {
391        Self {
392            base_url: base_url.into(),
393            timeout: Duration::from_secs(30),
394            retry: RetryConfig::default(),
395            rpc_url: None,
396            submit_url: None,
397            sender: None,
398        }
399    }
400
401    /// Set the Ethereum JSON-RPC endpoint for nonce/fee queries and receipt polling.
402    ///
403    /// Required before calling [`build`](Self::build). Not needed for
404    /// [`build_quote_only`](Self::build_quote_only).
405    pub fn with_rpc_url(mut self, rpc_url: impl Into<String>) -> Self {
406        self.rpc_url = Some(rpc_url.into());
407        self
408    }
409
410    /// Set the HTTP request timeout for Fynd API calls (default: 30 s).
411    pub fn with_timeout(mut self, timeout: Duration) -> Self {
412        self.timeout = timeout;
413        self
414    }
415
416    /// Override the retry configuration (default: 3 attempts, 100 ms / 2 s back-off).
417    pub fn with_retry(mut self, retry: RetryConfig) -> Self {
418        self.retry = retry;
419        self
420    }
421
422    /// Use a separate RPC URL for transaction submission and receipt polling.
423    ///
424    /// If not set, the URL from [`with_rpc_url`](Self::with_rpc_url) is used for both.
425    pub fn with_submit_url(mut self, url: impl Into<String>) -> Self {
426        self.submit_url = Some(url.into());
427        self
428    }
429
430    /// Set the default sender address used when [`SigningHints::sender`] is `None`.
431    pub fn with_sender(mut self, sender: Address) -> Self {
432        self.sender = Some(sender);
433        self
434    }
435
436    /// Build a [`FyndClient`] without connecting to an Ethereum RPC node.
437    ///
438    /// Suitable for [`FyndClient::quote`] and [`FyndClient::health`] calls only.
439    /// [`FyndClient::swap_payload`] and [`FyndClient::execute_swap`] require a live RPC URL and
440    /// will fail if called on a client built this way.
441    ///
442    /// Returns [`FyndError::Config`] if `base_url` is invalid.
443    pub fn build_quote_only(self) -> Result<FyndClient, FyndError> {
444        let parsed_base = self
445            .base_url
446            .parse::<reqwest::Url>()
447            .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
448        let scheme = parsed_base.scheme();
449        if scheme != "http" && scheme != "https" {
450            return Err(FyndError::Config(format!(
451                "base URL must use http or https scheme, got '{scheme}'"
452            )));
453        }
454
455        // Use dummy providers pointing at the base URL.
456        // These are never invoked for quote/health operations.
457        let provider = ProviderBuilder::default().connect_http(parsed_base.clone());
458        let submit_provider = ProviderBuilder::default().connect_http(parsed_base);
459
460        let http = HttpClient::builder()
461            .timeout(self.timeout)
462            .build()
463            .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
464
465        Ok(FyndClient {
466            http,
467            base_url: self.base_url,
468            retry: self.retry,
469            chain_id: 1,
470            default_sender: self.sender,
471            provider,
472            submit_provider,
473            info_cache: tokio::sync::OnceCell::new(),
474        })
475    }
476
477    /// Connect to the Ethereum RPC node and build the [`FyndClient`].
478    ///
479    /// Requires [`with_rpc_url`](Self::with_rpc_url) to have been called.
480    /// Validates the URLs and fetches the chain ID. Returns [`FyndError::Config`] if any URL is
481    /// invalid, `rpc_url` was not set, or the chain ID cannot be fetched.
482    pub async fn build(self) -> Result<FyndClient, FyndError> {
483        // Validate base_url scheme.
484        let parsed_base = self
485            .base_url
486            .parse::<reqwest::Url>()
487            .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
488        let scheme = parsed_base.scheme();
489        if scheme != "http" && scheme != "https" {
490            return Err(FyndError::Config(format!(
491                "base URL must use http or https scheme, got '{scheme}'"
492            )));
493        }
494
495        // Build HTTP providers.
496        let rpc_url_str = self
497            .rpc_url
498            .ok_or_else(|| FyndError::Config("rpc_url is required: call with_rpc_url()".into()))?;
499        let rpc_url = rpc_url_str
500            .parse::<reqwest::Url>()
501            .map_err(|e| FyndError::Config(format!("invalid RPC URL: {e}")))?;
502        let provider = ProviderBuilder::default().connect_http(rpc_url);
503
504        let submit_url_str = self
505            .submit_url
506            .as_deref()
507            .unwrap_or(&rpc_url_str);
508        let submit_url = submit_url_str
509            .parse::<reqwest::Url>()
510            .map_err(|e| FyndError::Config(format!("invalid submit URL: {e}")))?;
511        let submit_provider = ProviderBuilder::default().connect_http(submit_url);
512
513        // Fetch chain_id from the RPC node.
514        let chain_id = provider
515            .get_chain_id()
516            .await
517            .map_err(|e| FyndError::Config(format!("failed to fetch chain_id from RPC: {e}")))?;
518
519        // Build HTTP client.
520        let http = HttpClient::builder()
521            .timeout(self.timeout)
522            .build()
523            .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
524
525        Ok(FyndClient {
526            http,
527            base_url: self.base_url,
528            retry: self.retry,
529            chain_id,
530            default_sender: self.sender,
531            provider,
532            submit_provider,
533            info_cache: tokio::sync::OnceCell::new(),
534        })
535    }
536}
537
538// ============================================================================
539// FYND CLIENT
540// ============================================================================
541
542/// The main entry point for interacting with the Fynd DEX router.
543///
544/// Construct via [`FyndClientBuilder`]. All methods are `async` and require a Tokio runtime.
545///
546/// The type parameter `P` is the alloy provider used for Ethereum RPC calls. In production code
547/// this is `RootProvider<Ethereum>` (the default). In tests a mocked provider can be used.
548pub struct FyndClient<P = RootProvider<Ethereum>>
549where
550    P: Provider<Ethereum> + Clone + Send + Sync + 'static,
551{
552    http: HttpClient,
553    base_url: String,
554    retry: RetryConfig,
555    chain_id: u64,
556    default_sender: Option<Address>,
557    provider: P,
558    submit_provider: P,
559    info_cache: tokio::sync::OnceCell<InstanceInfo>,
560}
561
562impl<P> FyndClient<P>
563where
564    P: Provider<Ethereum> + Clone + Send + Sync + 'static,
565{
566    /// Construct a client directly from its individual fields.
567    ///
568    /// Intended for testing only. Use [`FyndClientBuilder`] for production code.
569    #[doc(hidden)]
570    #[allow(clippy::too_many_arguments)]
571    pub fn new_with_providers(
572        http: HttpClient,
573        base_url: String,
574        retry: RetryConfig,
575        chain_id: u64,
576        default_sender: Option<Address>,
577        provider: P,
578        submit_provider: P,
579    ) -> Self {
580        Self {
581            http,
582            base_url,
583            retry,
584            chain_id,
585            default_sender,
586            provider,
587            submit_provider,
588            info_cache: tokio::sync::OnceCell::new(),
589        }
590    }
591
592    /// Request a quote for one or more swap orders.
593    ///
594    /// The returned `Quote` has `token_out` and `receiver` populated on each
595    /// `OrderQuote` from the corresponding input `Order` (matched by index).
596    ///
597    /// Retries automatically on transient failures according to the client's [`RetryConfig`].
598    pub async fn quote(&self, params: QuoteParams) -> Result<Quote, FyndError> {
599        let token_out = params.order.token_out().clone();
600        let receiver = params
601            .order
602            .receiver()
603            .unwrap_or_else(|| params.order.sender())
604            .clone();
605        let dto_request = mapping::quote_params_to_dto(params)?;
606
607        let mut delay = self.retry.initial_backoff;
608        for attempt in 0..self.retry.max_attempts {
609            match self
610                .request_quote(&dto_request, token_out.clone(), receiver.clone())
611                .await
612            {
613                Ok(quote) => return Ok(quote),
614                Err(e) if e.is_retryable() && attempt + 1 < self.retry.max_attempts => {
615                    tracing::debug!(attempt, "quote request failed, retrying");
616                    tokio::time::sleep(delay).await;
617                    delay = (delay * 2).min(self.retry.max_backoff);
618                }
619                Err(e) => return Err(e),
620            }
621        }
622        Err(FyndError::Protocol("retry loop exhausted without result".into()))
623    }
624
625    async fn request_quote(
626        &self,
627        dto_request: &fynd_rpc_types::QuoteRequest,
628        token_out: Bytes,
629        receiver: Bytes,
630    ) -> Result<Quote, FyndError> {
631        let url = format!("{}/v1/quote", self.base_url);
632        let response = self
633            .http
634            .post(&url)
635            .json(dto_request)
636            .send()
637            .await?;
638        if !response.status().is_success() {
639            let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
640            return Err(mapping::dto_error_to_fynd(dto_err));
641        }
642        let dto_quote: fynd_rpc_types::Quote = response.json().await?;
643        mapping::map_quote_response(dto_quote, vec![(token_out, receiver)])?
644            .into_iter()
645            .next()
646            .ok_or_else(|| FyndError::Protocol("server returned empty quote list".into()))
647    }
648
649    /// Request quotes for multiple swap orders in a single round-trip.
650    ///
651    /// All orders share the same [`crate::QuoteOptions`]. The returned vec is index-aligned with
652    /// the input: `quotes[i]` corresponds to `params.orders[i]`. For any error response, the entire
653    /// request is considered for a retry. Partial responses are not returned.
654    ///
655    /// Retries automatically on transient failures according to the client's [`RetryConfig`].
656    pub async fn batch_quote(&self, params: BatchQuoteParams) -> Result<Vec<Quote>, FyndError> {
657        let (dto_request, order_meta) = mapping::batch_quote_params_to_dto(params)?;
658
659        let mut delay = self.retry.initial_backoff;
660        for attempt in 0..self.retry.max_attempts {
661            match self
662                .request_batch_quote(&dto_request, order_meta.clone())
663                .await
664            {
665                Ok(quotes) => return Ok(quotes),
666                Err(e) if e.is_retryable() && attempt + 1 < self.retry.max_attempts => {
667                    tracing::debug!(attempt, "batch_quote request failed, retrying");
668                    tokio::time::sleep(delay).await;
669                    delay = (delay * 2).min(self.retry.max_backoff);
670                }
671                Err(e) => return Err(e),
672            }
673        }
674        Err(FyndError::Protocol("retry loop exhausted without result".into()))
675    }
676
677    async fn request_batch_quote(
678        &self,
679        dto_request: &fynd_rpc_types::QuoteRequest,
680        order_meta: Vec<(Bytes, Bytes)>,
681    ) -> Result<Vec<Quote>, FyndError> {
682        let url = format!("{}/v1/quote", self.base_url);
683        let response = self
684            .http
685            .post(&url)
686            .json(dto_request)
687            .send()
688            .await?;
689        if !response.status().is_success() {
690            let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
691            return Err(mapping::dto_error_to_fynd(dto_err));
692        }
693        let dto_quote: fynd_rpc_types::Quote = response.json().await?;
694        mapping::map_quote_response(dto_quote, order_meta)
695    }
696
697    /// Get the health status of the Fynd RPC server.
698    pub async fn health(&self) -> Result<HealthStatus, FyndError> {
699        let url = format!("{}/v1/health", self.base_url);
700        let response = self.http.get(&url).send().await?;
701        let status = response.status();
702        let body = response.text().await?;
703        // The server returns HealthStatus JSON for both 200 and 503 (not-ready).
704        // Try parsing as HealthStatus first, then fall back to ErrorResponse.
705        if let Ok(dh) = serde_json::from_str::<fynd_rpc_types::HealthStatus>(&body) {
706            return Ok(HealthStatus::from(dh));
707        }
708        if let Ok(dto_err) = serde_json::from_str::<fynd_rpc_types::ErrorResponse>(&body) {
709            return Err(mapping::dto_error_to_fynd(dto_err));
710        }
711        Err(FyndError::Protocol(format!("unexpected health response ({status}): {body}")))
712    }
713
714    /// Build a swap payload for a given order quote, ready for signing.
715    ///
716    /// For [`BackendKind::Fynd`] quotes, this resolves the sender nonce and EIP-1559 fee
717    /// parameters from the RPC node (unless overridden via `hints`), then constructs an
718    /// unsigned EIP-1559 transaction targeting the RouterV3 contract.
719    ///
720    /// [`BackendKind::Turbine`] is not yet implemented and returns
721    /// [`FyndError::Protocol`].
722    ///
723    /// `token_out` and `receiver` are read directly from the `quote` (populated during
724    /// `quote()`). Pass `&SigningHints::default()` to auto-resolve all transaction parameters.
725    pub async fn swap_payload(
726        &self,
727        quote: Quote,
728        hints: &SigningHints,
729    ) -> Result<SwapPayload, FyndError> {
730        match quote.backend() {
731            BackendKind::Fynd => {
732                self.fynd_swap_payload(quote, hints)
733                    .await
734            }
735            BackendKind::Turbine => {
736                Err(FyndError::Protocol("Turbine signing not yet implemented".into()))
737            }
738        }
739    }
740
741    async fn fynd_swap_payload(
742        &self,
743        quote: Quote,
744        hints: &SigningHints,
745    ) -> Result<SwapPayload, FyndError> {
746        // Resolve sender.
747        let sender = hints
748            .sender()
749            .or(self.default_sender)
750            .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
751
752        // Resolve nonce.
753        let nonce = match hints.nonce() {
754            Some(n) => n,
755            None => self
756                .provider
757                .get_transaction_count(sender)
758                .await
759                .map_err(FyndError::Provider)?,
760        };
761
762        // Resolve EIP-1559 fees.
763        let (max_fee_per_gas, max_priority_fee_per_gas) =
764            match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
765                (Some(mf), Some(mp)) => (mf, mp),
766                (mf, mp) => {
767                    let est = self
768                        .provider
769                        .estimate_eip1559_fees()
770                        .await
771                        .map_err(FyndError::Provider)?;
772                    (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
773                }
774            };
775
776        let tx_data = quote.transaction().ok_or_else(|| {
777            FyndError::Protocol(
778                "quote has no calldata; set encoding_options in QuoteOptions".into(),
779            )
780        })?;
781        let to_addr = mapping::bytes_to_alloy_address(tx_data.to())?;
782        let value = mapping::biguint_to_u256(tx_data.value());
783        let input = AlloyBytes::from(tx_data.data().to_vec());
784
785        // Resolve gas limit. If not explicitly set, estimate via eth_estimateGas so the
786        // limit reflects the actual chain state. Pass with_gas_limit() to use a fixed value
787        // instead (e.g. quote.gas_estimate() as a pre-buffered fallback).
788        let gas_limit = match hints.gas_limit() {
789            Some(g) => g,
790            None => {
791                let req = alloy::rpc::types::TransactionRequest::default()
792                    .from(sender)
793                    .to(to_addr)
794                    .value(value)
795                    .input(input.clone().into());
796                self.provider
797                    .estimate_gas(req)
798                    .await
799                    .map_err(FyndError::Provider)?
800            }
801        };
802
803        let tx_eip1559 = TxEip1559 {
804            chain_id: self.chain_id,
805            nonce,
806            max_fee_per_gas,
807            max_priority_fee_per_gas,
808            gas_limit,
809            to: TxKind::Call(to_addr),
810            value,
811            input,
812            access_list: AccessList::default(),
813        };
814
815        // Optionally simulate the transaction.
816        if hints.simulate() {
817            let req = alloy::rpc::types::TransactionRequest::from_transaction_with_sender(
818                tx_eip1559.clone(),
819                sender,
820            );
821            self.provider
822                .call(req)
823                .await
824                .map_err(|e| {
825                    FyndError::SimulationFailed(format!("transaction simulation failed: {e}"))
826                })?;
827        }
828
829        let tx = TypedTransaction::Eip1559(tx_eip1559);
830        Ok(SwapPayload::Fynd(Box::new(FyndPayload::new(quote, tx))))
831    }
832
833    /// Broadcast a signed swap and return an [`ExecutionReceipt`] that resolves once the
834    /// transaction is mined.
835    ///
836    /// Pass [`ExecutionOptions::default`] for standard on-chain submission. Set
837    /// [`ExecutionOptions::dry_run`] to `true` to simulate only — the receipt resolves immediately
838    /// with values derived from `eth_call` (settled amount) and `eth_estimateGas` (gas cost).
839    ///
840    /// For real submissions, this method returns **immediately** after broadcasting. The inner
841    /// future polls every 2 seconds and has no built-in timeout; wrap with
842    /// [`tokio::time::timeout`] to bound the wait.
843    pub async fn execute_swap(
844        &self,
845        order: SignedSwap,
846        options: &ExecutionOptions,
847    ) -> Result<ExecutionReceipt, FyndError> {
848        let (payload, signature) = order.into_parts();
849        let (quote, tx) = payload.into_fynd_parts()?;
850
851        let TypedTransaction::Eip1559(tx_eip1559) = tx else {
852            return Err(FyndError::Protocol(
853                "only EIP-1559 transactions are supported for execution".into(),
854            ));
855        };
856
857        if options.dry_run {
858            return self
859                .dry_run_execute(tx_eip1559, options)
860                .await;
861        }
862
863        let tx_hash = self
864            .send_raw(tx_eip1559.clone(), signature)
865            .await?;
866
867        let token_out_addr = mapping::bytes_to_alloy_address(quote.token_out())?;
868        let receiver_addr = mapping::bytes_to_alloy_address(quote.receiver())?;
869        let provider = self.submit_provider.clone();
870        let fetch_revert = options.fetch_revert_reason;
871        // Pre-build the eth_call fallback request from the original transaction.
872        // `from` is omitted — eth_call does not require it and `sender` is not
873        // available in the outer execute_swap context.
874        let fallback_to = match tx_eip1559.to {
875            TxKind::Call(addr) => addr,
876            TxKind::Create => Address::ZERO,
877        };
878        let fallback_req = TransactionRequest::default()
879            .to(fallback_to)
880            .value(tx_eip1559.value)
881            .input(tx_eip1559.input.clone().into());
882
883        Ok(ExecutionReceipt::Transaction(Box::pin(async move {
884            loop {
885                match provider
886                    .get_transaction_receipt(tx_hash)
887                    .await
888                    .map_err(FyndError::Provider)?
889                {
890                    Some(receipt) => {
891                        if !receipt.status() {
892                            let reason = if fetch_revert {
893                                // Inline the revert_reason logic (no self available here).
894                                let trace: Result<serde_json::Value, _> = provider
895                                    .raw_request(
896                                        std::borrow::Cow::Borrowed("debug_traceTransaction"),
897                                        (tx_hash, serde_json::json!({})),
898                                    )
899                                    .await;
900                                match trace {
901                                    Ok(t) => {
902                                        let hex_str = t
903                                            .get("returnValue")
904                                            .and_then(|v| v.as_str())
905                                            .unwrap_or("");
906                                        match alloy::primitives::hex::decode(
907                                            hex_str.trim_start_matches("0x"),
908                                        ) {
909                                            Ok(b) => decode_revert_bytes(&b),
910                                            Err(_) => format!(
911                                                "{tx_hash:#x} reverted (return value: {hex_str})"
912                                            ),
913                                        }
914                                    }
915                                    Err(_) => {
916                                        tracing::warn!(
917                                            tx = ?tx_hash,
918                                            "debug_traceTransaction unavailable; replaying via \
919                                             eth_call — block state may differ"
920                                        );
921                                        match provider.call(fallback_req).await {
922                                            Err(e) => e.to_string(),
923                                            Ok(_) => {
924                                                format!("{tx_hash:#x} reverted (no reason)")
925                                            }
926                                        }
927                                    }
928                                }
929                            } else {
930                                format!("{tx_hash:#x}")
931                            };
932                            return Err(FyndError::TransactionReverted(reason));
933                        }
934                        let settled_amount =
935                            compute_settled_amount(&receipt, &token_out_addr, &receiver_addr);
936                        let gas_cost = BigUint::from(receipt.gas_used) *
937                            BigUint::from(receipt.effective_gas_price);
938                        return Ok(SettledOrder::new(Some(tx_hash), settled_amount, gas_cost));
939                    }
940                    None => tokio::time::sleep(Duration::from_secs(2)).await,
941                }
942            }
943        })))
944    }
945
946    /// Fetch and cache static instance metadata from `GET /v1/info`.
947    ///
948    /// The result is fetched at most once per [`FyndClient`] instance; subsequent calls return the
949    /// cached value without making a network request.
950    pub async fn info(&self) -> Result<&InstanceInfo, FyndError> {
951        self.info_cache
952            .get_or_try_init(|| self.fetch_info())
953            .await
954    }
955
956    async fn fetch_info(&self) -> Result<InstanceInfo, FyndError> {
957        let url = format!("{}/v1/info", self.base_url);
958        let response = self.http.get(&url).send().await?;
959        if !response.status().is_success() {
960            let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
961            return Err(mapping::dto_error_to_fynd(dto_err));
962        }
963        let dto_info: fynd_rpc_types::InstanceInfo = response.json().await?;
964        dto_info.try_into()
965    }
966
967    /// Build an unsigned EIP-1559 `approve(spender, amount)` transaction for the given token,
968    /// or `None` if the allowance is already sufficient.
969    ///
970    /// 1. Calls [`info()`](Self::info) to resolve the spender address from `params.transfer_type`.
971    /// 2. If `params.allowance_check` is [`AllowanceCheck::AtLeast`], checks the current ERC-20
972    ///    allowance and returns `None` if it meets the threshold (skipping nonce and fee
973    ///    resolution). With [`AllowanceCheck::Skip`] the check is skipped and the approval payload
974    ///    is always built.
975    /// 3. Resolves nonce and EIP-1559 fees via `hints` (same semantics as
976    ///    [`swap_payload`](Self::swap_payload)).
977    /// 4. Encodes the `approve(spender, amount)` calldata using the ERC-20 ABI.
978    ///
979    /// Gas defaults to `hints.gas_limit().unwrap_or(65_000)`.
980    pub async fn approval(
981        &self,
982        params: &ApprovalParams,
983        hints: &SigningHints,
984    ) -> Result<Option<ApprovalPayload>, FyndError> {
985        use alloy::sol_types::SolCall;
986
987        let info = self.info().await?;
988        let spender_addr = match params.transfer_type {
989            UserTransferType::TransferFrom => {
990                mapping::bytes_to_alloy_address(info.router_address())?
991            }
992            UserTransferType::TransferFromPermit2 => {
993                mapping::bytes_to_alloy_address(info.permit2_address())?
994            }
995            UserTransferType::UseVaultsFunds => return Ok(None),
996        };
997
998        let sender = hints
999            .sender()
1000            .or(self.default_sender)
1001            .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
1002
1003        let token_addr = mapping::bytes_to_alloy_address(&params.token)?;
1004        let amount_u256 = mapping::biguint_to_u256(&params.amount);
1005
1006        // Check allowance before any other RPC calls so we can return early.
1007        if let AllowanceCheck::AtLeast(min) = &params.allowance_check {
1008            let call_data =
1009                erc20::allowanceCall { owner: sender, spender: spender_addr }.abi_encode();
1010            let req = alloy::rpc::types::TransactionRequest {
1011                to: Some(alloy::primitives::TxKind::Call(token_addr)),
1012                input: alloy::rpc::types::TransactionInput::new(AlloyBytes::from(call_data)),
1013                ..Default::default()
1014            };
1015            let result = self
1016                .provider
1017                .call(req)
1018                .await
1019                .map_err(|e| FyndError::Protocol(format!("allowance call failed: {e}")))?;
1020            let current_allowance = if result.len() >= 32 {
1021                alloy::primitives::U256::from_be_slice(&result[0..32])
1022            } else {
1023                alloy::primitives::U256::ZERO
1024            };
1025            if current_allowance >= mapping::biguint_to_u256(min) {
1026                return Ok(None);
1027            }
1028        }
1029
1030        // Resolve nonce.
1031        let nonce = match hints.nonce() {
1032            Some(n) => n,
1033            None => self
1034                .provider
1035                .get_transaction_count(sender)
1036                .await
1037                .map_err(FyndError::Provider)?,
1038        };
1039
1040        // Resolve EIP-1559 fees.
1041        let (max_fee_per_gas, max_priority_fee_per_gas) =
1042            match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
1043                (Some(mf), Some(mp)) => (mf, mp),
1044                (mf, mp) => {
1045                    let est = self
1046                        .provider
1047                        .estimate_eip1559_fees()
1048                        .await
1049                        .map_err(FyndError::Provider)?;
1050                    (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
1051                }
1052            };
1053
1054        let calldata =
1055            erc20::approveCall { spender: spender_addr, amount: amount_u256 }.abi_encode();
1056
1057        // Resolve gas limit via eth_estimateGas unless the caller provided an explicit value.
1058        let gas_limit = match hints.gas_limit() {
1059            Some(g) => g,
1060            None => {
1061                let req = alloy::rpc::types::TransactionRequest::default()
1062                    .from(sender)
1063                    .to(token_addr)
1064                    .input(AlloyBytes::from(calldata.clone()).into());
1065                self.provider
1066                    .estimate_gas(req)
1067                    .await
1068                    .map_err(FyndError::Provider)?
1069            }
1070        };
1071
1072        let tx = TxEip1559 {
1073            chain_id: self.chain_id,
1074            nonce,
1075            max_fee_per_gas,
1076            max_priority_fee_per_gas,
1077            gas_limit,
1078            to: alloy::primitives::TxKind::Call(token_addr),
1079            value: alloy::primitives::U256::ZERO,
1080            input: AlloyBytes::from(calldata),
1081            access_list: alloy::eips::eip2930::AccessList::default(),
1082        };
1083
1084        let spender = bytes::Bytes::copy_from_slice(spender_addr.as_slice());
1085        Ok(Some(ApprovalPayload {
1086            tx,
1087            token: params.token.clone(),
1088            spender,
1089            amount: params.amount.clone(),
1090        }))
1091    }
1092
1093    /// Broadcast a signed approval transaction and return a [`TxReceipt`] that resolves once
1094    /// the transaction is mined.
1095    ///
1096    /// This method returns immediately after broadcasting. The inner future polls every 2 seconds
1097    /// and has no built-in timeout; wrap with [`tokio::time::timeout`] to bound the wait.
1098    pub async fn execute_approval(&self, approval: SignedApproval) -> Result<TxReceipt, FyndError> {
1099        let (payload, signature) = approval.into_parts();
1100        let fallback_req = TransactionRequest::default()
1101            .to(mapping::bytes_to_alloy_address(&payload.token)?)
1102            .input(payload.tx.input.clone().into());
1103        let tx_hash = self
1104            .send_raw(payload.tx, signature)
1105            .await?;
1106        let provider = self.submit_provider.clone();
1107
1108        Ok(TxReceipt::Pending(Box::pin(async move {
1109            loop {
1110                match provider
1111                    .get_transaction_receipt(tx_hash)
1112                    .await
1113                    .map_err(FyndError::Provider)?
1114                {
1115                    Some(receipt) => {
1116                        if !receipt.status() {
1117                            let trace: Result<serde_json::Value, _> = provider
1118                                .raw_request(
1119                                    std::borrow::Cow::Borrowed("debug_traceTransaction"),
1120                                    (tx_hash, serde_json::json!({})),
1121                                )
1122                                .await;
1123                            let reason = match trace {
1124                                Ok(t) => {
1125                                    let hex_str = t
1126                                        .get("returnValue")
1127                                        .and_then(|v| v.as_str())
1128                                        .unwrap_or("");
1129                                    match alloy::primitives::hex::decode(
1130                                        hex_str.trim_start_matches("0x"),
1131                                    ) {
1132                                        Ok(b) => decode_revert_bytes(&b),
1133                                        Err(_) => format!(
1134                                            "{tx_hash:#x} reverted (return value: {hex_str})"
1135                                        ),
1136                                    }
1137                                }
1138                                Err(_) => {
1139                                    tracing::warn!(
1140                                        tx = ?tx_hash,
1141                                        "debug_traceTransaction unavailable; replaying via \
1142                                         eth_call — block state may differ"
1143                                    );
1144                                    match provider.call(fallback_req).await {
1145                                        Err(e) => e.to_string(),
1146                                        Ok(_) => format!("{tx_hash:#x} reverted (no reason)"),
1147                                    }
1148                                }
1149                            };
1150                            return Err(FyndError::TransactionReverted(reason));
1151                        }
1152                        let gas_cost = BigUint::from(receipt.gas_used) *
1153                            BigUint::from(receipt.effective_gas_price);
1154                        return Ok(MinedTx::new(tx_hash, gas_cost));
1155                    }
1156                    None => tokio::time::sleep(Duration::from_secs(2)).await,
1157                }
1158            }
1159        })))
1160    }
1161
1162    /// Encode, sign, and broadcast an EIP-1559 transaction, returning its hash.
1163    async fn send_raw(
1164        &self,
1165        tx: TxEip1559,
1166        signature: alloy::primitives::Signature,
1167    ) -> Result<B256, FyndError> {
1168        use alloy::eips::eip2718::Encodable2718;
1169        let envelope = TypedTransaction::Eip1559(tx).into_envelope(signature);
1170        let raw = envelope.encoded_2718();
1171        let pending = self
1172            .submit_provider
1173            .send_raw_transaction(&raw)
1174            .await
1175            .map_err(FyndError::Provider)?;
1176        Ok(*pending.tx_hash())
1177    }
1178
1179    async fn dry_run_execute(
1180        &self,
1181        tx_eip1559: TxEip1559,
1182        options: &ExecutionOptions,
1183    ) -> Result<ExecutionReceipt, FyndError> {
1184        let mut req: TransactionRequest = tx_eip1559.clone().into();
1185        if let Some(sender) = self.default_sender {
1186            req.from = Some(sender);
1187        }
1188        let overrides = options
1189            .storage_overrides
1190            .as_ref()
1191            .map(storage_overrides_to_alloy)
1192            .transpose()?;
1193
1194        let return_data = self
1195            .provider
1196            .call(req.clone())
1197            .overrides_opt(overrides.clone())
1198            .await
1199            .map_err(|e| FyndError::SimulationFailed(format!("dry run simulation failed: {e}")))?;
1200
1201        let gas_used = self
1202            .provider
1203            .estimate_gas(req)
1204            .overrides_opt(overrides)
1205            .await
1206            .map_err(|e| {
1207                FyndError::SimulationFailed(format!("dry run gas estimation failed: {e}"))
1208            })?;
1209
1210        let settled_amount = if return_data.len() >= 32 {
1211            Some(BigUint::from_bytes_be(&return_data[0..32]))
1212        } else {
1213            None
1214        };
1215        let gas_cost = BigUint::from(gas_used) * BigUint::from(tx_eip1559.max_fee_per_gas);
1216        let settled = SettledOrder::new(None, settled_amount, gas_cost);
1217
1218        Ok(ExecutionReceipt::Transaction(Box::pin(async move { Ok(settled) })))
1219    }
1220}
1221
1222/// Decode a standard Solidity `Error(string)` revert payload.
1223///
1224/// Returns the decoded string for `0x08c379a0`-prefixed data, or a hex dump
1225/// for unrecognised payloads.
1226fn decode_revert_bytes(data: &[u8]) -> String {
1227    // Error(string): selector(4) + offset(32) + length(32) + string_bytes
1228    const SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0];
1229    if data.len() >= 68 && data[..4] == SELECTOR {
1230        let str_len = u64::from_be_bytes(
1231            data[60..68]
1232                .try_into()
1233                .unwrap_or([0u8; 8]),
1234        ) as usize;
1235        if data.len() >= 68 + str_len {
1236            if let Ok(s) = std::str::from_utf8(&data[68..68 + str_len]) {
1237                return s.to_owned();
1238            }
1239        }
1240    }
1241    if data.is_empty() {
1242        "empty revert data".to_owned()
1243    } else {
1244        format!("0x{}", alloy::primitives::hex::encode(data))
1245    }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use std::time::Duration;
1251
1252    use super::*;
1253
1254    #[test]
1255    fn retry_config_default_values() {
1256        let config = RetryConfig::default();
1257        assert_eq!(config.max_attempts(), 3);
1258        assert_eq!(config.initial_backoff(), Duration::from_millis(100));
1259        assert_eq!(config.max_backoff(), Duration::from_secs(2));
1260    }
1261
1262    #[test]
1263    fn signing_hints_default_all_none_and_no_simulate() {
1264        let hints = SigningHints::default();
1265        assert!(hints.sender().is_none());
1266        assert!(hints.nonce().is_none());
1267        assert!(!hints.simulate());
1268    }
1269
1270    // ========================================================================
1271    // Helpers shared by the HTTP-level tests below
1272    // ========================================================================
1273
1274    /// Build a minimal valid [`FyndClient<RootProvider<Ethereum>>`] pointing at a mock HTTP
1275    /// server URL, using the alloy mock transport for the provider.
1276    ///
1277    /// Returns the client and the alloy asserter so tests can pre-load RPC responses.
1278    fn make_test_client(
1279        base_url: String,
1280        retry: RetryConfig,
1281        default_sender: Option<Address>,
1282    ) -> (FyndClient<alloy::providers::RootProvider<Ethereum>>, alloy::providers::mock::Asserter)
1283    {
1284        use alloy::providers::{mock::Asserter, ProviderBuilder};
1285
1286        let asserter = Asserter::new();
1287        let provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1288        let submit_provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1289
1290        let http = HttpClient::builder()
1291            .timeout(Duration::from_secs(5))
1292            .build()
1293            .expect("reqwest client");
1294
1295        let client = FyndClient::new_with_providers(
1296            http,
1297            base_url,
1298            retry,
1299            1,
1300            default_sender,
1301            provider,
1302            submit_provider,
1303        );
1304
1305        (client, asserter)
1306    }
1307
1308    /// Build a minimal valid `OrderQuote` for use in tests.
1309    fn make_order_quote() -> crate::types::Quote {
1310        use num_bigint::BigUint;
1311
1312        use crate::types::{BackendKind, BlockInfo, QuoteStatus, Transaction};
1313
1314        let tx = Transaction::new(
1315            bytes::Bytes::copy_from_slice(&[0x01; 20]),
1316            BigUint::ZERO,
1317            vec![0x12, 0x34],
1318        );
1319
1320        crate::types::Quote::new(
1321            "test-order-id".to_string(),
1322            QuoteStatus::Success,
1323            BackendKind::Fynd,
1324            None,
1325            BigUint::from(1_000_000u64),
1326            BigUint::from(990_000u64),
1327            BigUint::from(50_000u64),
1328            BigUint::from(940_000u64),
1329            Some(10),
1330            BlockInfo::new(1_234_567, "0xabcdef".to_string(), 1_700_000_000),
1331            bytes::Bytes::copy_from_slice(&[0xbb; 20]),
1332            bytes::Bytes::copy_from_slice(&[0xcc; 20]),
1333            Some(tx),
1334            None,
1335        )
1336    }
1337
1338    // ========================================================================
1339    // quote() tests
1340    // ========================================================================
1341
1342    #[tokio::test]
1343    async fn quote_returns_parsed_quote_on_success() {
1344        use wiremock::{
1345            matchers::{method, path},
1346            Mock, MockServer, ResponseTemplate,
1347        };
1348
1349        let server = MockServer::start().await;
1350        let body = serde_json::json!({
1351            "orders": [{
1352                "order_id": "abc-123",
1353                "status": "success",
1354                "amount_in": "1000000",
1355                "amount_out": "990000",
1356                "gas_estimate": "50000",
1357                "amount_out_net_gas": "940000",
1358                "price_impact_bps": 10,
1359                "block": {
1360                    "number": 1234567,
1361                    "hash": "0xabcdef",
1362                    "timestamp": 1700000000
1363                }
1364            }],
1365            "total_gas_estimate": "50000",
1366            "solve_time_ms": 42
1367        });
1368
1369        Mock::given(method("POST"))
1370            .and(path("/v1/quote"))
1371            .respond_with(ResponseTemplate::new(200).set_body_json(body))
1372            .expect(1)
1373            .mount(&server)
1374            .await;
1375
1376        let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1377
1378        let params = make_quote_params();
1379        let quote = client
1380            .quote(params)
1381            .await
1382            .expect("quote should succeed");
1383
1384        assert_eq!(quote.order_id(), "abc-123");
1385        assert_eq!(quote.amount_out(), &num_bigint::BigUint::from(990_000u64));
1386    }
1387
1388    #[tokio::test]
1389    async fn quote_returns_api_error_on_non_retryable_server_error() {
1390        use wiremock::{
1391            matchers::{method, path},
1392            Mock, MockServer, ResponseTemplate,
1393        };
1394
1395        use crate::error::ErrorCode;
1396
1397        let server = MockServer::start().await;
1398
1399        Mock::given(method("POST"))
1400            .and(path("/v1/quote"))
1401            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1402                "error": "bad input",
1403                "code": "BAD_REQUEST"
1404            })))
1405            .expect(1)
1406            .mount(&server)
1407            .await;
1408
1409        let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1410
1411        let err = client
1412            .quote(make_quote_params())
1413            .await
1414            .unwrap_err();
1415        assert!(
1416            matches!(err, FyndError::Api { code: ErrorCode::BadRequest, .. }),
1417            "expected BadRequest, got {err:?}"
1418        );
1419    }
1420
1421    #[tokio::test]
1422    async fn quote_retries_on_retryable_error_then_succeeds() {
1423        use wiremock::{
1424            matchers::{method, path},
1425            Mock, MockServer, ResponseTemplate,
1426        };
1427
1428        let server = MockServer::start().await;
1429
1430        // First attempt: service unavailable.
1431        Mock::given(method("POST"))
1432            .and(path("/v1/quote"))
1433            .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1434                "error": "queue full",
1435                "code": "QUEUE_FULL"
1436            })))
1437            .up_to_n_times(1)
1438            .mount(&server)
1439            .await;
1440
1441        // Second attempt: success.
1442        let success_body = serde_json::json!({
1443            "orders": [{
1444                "order_id": "retry-order",
1445                "status": "success",
1446                "amount_in": "1000000",
1447                "amount_out": "990000",
1448                "gas_estimate": "50000",
1449                "amount_out_net_gas": "940000",
1450                "price_impact_bps": null,
1451                "block": {
1452                    "number": 1234568,
1453                    "hash": "0xabcdef01",
1454                    "timestamp": 1700000012
1455                }
1456            }],
1457            "total_gas_estimate": "50000",
1458            "solve_time_ms": 10
1459        });
1460        Mock::given(method("POST"))
1461            .and(path("/v1/quote"))
1462            .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
1463            .up_to_n_times(1)
1464            .mount(&server)
1465            .await;
1466
1467        let retry = RetryConfig::new(3, Duration::from_millis(1), Duration::from_millis(10));
1468        let (client, _asserter) = make_test_client(server.uri(), retry, None);
1469
1470        let quote = client
1471            .quote(make_quote_params())
1472            .await
1473            .expect("should succeed after retry");
1474        assert_eq!(quote.order_id(), "retry-order");
1475    }
1476
1477    #[tokio::test]
1478    async fn quote_exhausts_retries_and_returns_last_error() {
1479        use wiremock::{
1480            matchers::{method, path},
1481            Mock, MockServer, ResponseTemplate,
1482        };
1483
1484        use crate::error::ErrorCode;
1485
1486        let server = MockServer::start().await;
1487
1488        Mock::given(method("POST"))
1489            .and(path("/v1/quote"))
1490            .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1491                "error": "queue full",
1492                "code": "QUEUE_FULL"
1493            })))
1494            .mount(&server)
1495            .await;
1496
1497        let retry = RetryConfig::new(2, Duration::from_millis(1), Duration::from_millis(10));
1498        let (client, _asserter) = make_test_client(server.uri(), retry, None);
1499
1500        let err = client
1501            .quote(make_quote_params())
1502            .await
1503            .unwrap_err();
1504        assert!(
1505            matches!(err, FyndError::Api { code: ErrorCode::ServiceUnavailable, .. }),
1506            "expected ServiceUnavailable after retry exhaustion, got {err:?}"
1507        );
1508    }
1509
1510    #[tokio::test]
1511    async fn quote_returns_error_on_malformed_response() {
1512        use wiremock::{
1513            matchers::{method, path},
1514            Mock, MockServer, ResponseTemplate,
1515        };
1516
1517        let server = MockServer::start().await;
1518
1519        Mock::given(method("POST"))
1520            .and(path("/v1/quote"))
1521            .respond_with(
1522                ResponseTemplate::new(200).set_body_json(serde_json::json!({"garbage": true})),
1523            )
1524            .mount(&server)
1525            .await;
1526
1527        let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1528
1529        let err = client
1530            .quote(make_quote_params())
1531            .await
1532            .unwrap_err();
1533        // Deserialization failure is wrapped as FyndError::Http (from reqwest json decoding).
1534        assert!(
1535            matches!(err, FyndError::Http(_)),
1536            "expected Http deserialization error, got {err:?}"
1537        );
1538    }
1539
1540    // ========================================================================
1541    // health() tests
1542    // ========================================================================
1543
1544    #[tokio::test]
1545    async fn health_returns_status_on_success() {
1546        use wiremock::{
1547            matchers::{method, path},
1548            Mock, MockServer, ResponseTemplate,
1549        };
1550
1551        let server = MockServer::start().await;
1552
1553        Mock::given(method("GET"))
1554            .and(path("/v1/health"))
1555            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1556                "healthy": true,
1557                "last_update_ms": 100,
1558                "num_solver_pools": 5
1559            })))
1560            .expect(1)
1561            .mount(&server)
1562            .await;
1563
1564        let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1565
1566        let status = client
1567            .health()
1568            .await
1569            .expect("health should succeed");
1570        assert!(status.healthy());
1571        assert_eq!(status.last_update_ms(), 100);
1572        assert_eq!(status.num_solver_pools(), 5);
1573    }
1574
1575    #[tokio::test]
1576    async fn health_returns_error_on_server_failure() {
1577        use wiremock::{
1578            matchers::{method, path},
1579            Mock, MockServer, ResponseTemplate,
1580        };
1581
1582        let server = MockServer::start().await;
1583
1584        Mock::given(method("GET"))
1585            .and(path("/v1/health"))
1586            .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1587                "error": "service unavailable",
1588                "code": "NOT_READY"
1589            })))
1590            .expect(1)
1591            .mount(&server)
1592            .await;
1593
1594        let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1595
1596        let err = client.health().await.unwrap_err();
1597        assert!(matches!(err, FyndError::Api { .. }), "expected Api error, got {err:?}");
1598    }
1599
1600    // ========================================================================
1601    // swap_payload() tests
1602    // ========================================================================
1603
1604    #[tokio::test]
1605    async fn swap_payload_uses_hints_when_all_provided() {
1606        let sender = Address::with_last_byte(0xab);
1607        let (client, _asserter) =
1608            make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1609
1610        let quote = make_order_quote();
1611        let hints = SigningHints {
1612            sender: Some(sender),
1613            nonce: Some(5),
1614            max_fee_per_gas: Some(1_000_000_000),
1615            max_priority_fee_per_gas: Some(1_000_000),
1616            gas_limit: Some(100_000),
1617            simulate: false,
1618        };
1619
1620        let payload = client
1621            .swap_payload(quote, &hints)
1622            .await
1623            .expect("swap_payload should succeed");
1624
1625        let SwapPayload::Fynd(fynd) = payload else {
1626            panic!("expected Fynd payload");
1627        };
1628        let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1629            panic!("expected EIP-1559 transaction");
1630        };
1631        assert_eq!(tx.nonce, 5);
1632        assert_eq!(tx.max_fee_per_gas, 1_000_000_000);
1633        assert_eq!(tx.max_priority_fee_per_gas, 1_000_000);
1634        assert_eq!(tx.gas_limit, 100_000);
1635    }
1636
1637    #[tokio::test]
1638    async fn swap_payload_fetches_nonce_and_fees_when_hints_absent() {
1639        let sender = Address::with_last_byte(0xde);
1640        let (client, asserter) =
1641            make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1642
1643        // eth_getTransactionCount → nonce 7
1644        asserter.push_success(&7u64);
1645        // estimate_eip1559_fees calls eth_feeHistory; push two values for the response
1646        // alloy's estimate_eip1559_fees uses eth_feeHistory; we push a plausible response.
1647        // The estimate_eip1559_fees method calls eth_feeHistory with 1 block, 25/75 percentiles.
1648        let fee_history = serde_json::json!({
1649            "oldestBlock": "0x1",
1650            "baseFeePerGas": ["0x3b9aca00", "0x3b9aca00"],
1651            "gasUsedRatio": [0.5],
1652            "reward": [["0xf4240", "0x1e8480"]]
1653        });
1654        asserter.push_success(&fee_history);
1655        // eth_estimateGas → 150_000
1656        asserter.push_success(&150_000u64);
1657
1658        let quote = make_order_quote();
1659        let hints = SigningHints::default();
1660
1661        let payload = client
1662            .swap_payload(quote, &hints)
1663            .await
1664            .expect("swap_payload should succeed");
1665
1666        let SwapPayload::Fynd(fynd) = payload else {
1667            panic!("expected Fynd payload");
1668        };
1669        let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1670            panic!("expected EIP-1559 transaction");
1671        };
1672        assert_eq!(tx.nonce, 7, "nonce should come from mock");
1673        assert_eq!(tx.gas_limit, 150_000, "gas limit should come from eth_estimateGas");
1674    }
1675
1676    #[tokio::test]
1677    async fn swap_payload_returns_config_error_when_no_sender() {
1678        // No sender on client, no sender in hints.
1679        let (client, _asserter) =
1680            make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1681
1682        let quote = make_order_quote();
1683        let hints = SigningHints::default(); // no sender
1684
1685        let err = client
1686            .swap_payload(quote, &hints)
1687            .await
1688            .unwrap_err();
1689
1690        assert!(matches!(err, FyndError::Config(_)), "expected Config error, got {err:?}");
1691    }
1692
1693    #[tokio::test]
1694    async fn swap_payload_with_simulate_true_calls_eth_call_successfully() {
1695        let sender = Address::with_last_byte(0xab);
1696        let (client, asserter) =
1697            make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1698
1699        let quote = make_order_quote();
1700        let hints = SigningHints {
1701            sender: Some(sender),
1702            nonce: Some(1),
1703            max_fee_per_gas: Some(1_000_000_000),
1704            max_priority_fee_per_gas: Some(1_000_000),
1705            gas_limit: Some(100_000),
1706            simulate: true,
1707        };
1708
1709        // eth_call → success (empty bytes result)
1710        asserter.push_success(&alloy::primitives::Bytes::new());
1711
1712        let payload = client
1713            .swap_payload(quote, &hints)
1714            .await
1715            .expect("swap_payload with simulate=true should succeed");
1716
1717        assert!(matches!(payload, SwapPayload::Fynd(_)));
1718    }
1719
1720    #[tokio::test]
1721    async fn swap_payload_with_simulate_true_returns_simulation_failed_on_revert() {
1722        let sender = Address::with_last_byte(0xab);
1723        let (client, asserter) =
1724            make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1725
1726        let quote = make_order_quote();
1727        let hints = SigningHints {
1728            sender: Some(sender),
1729            nonce: Some(1),
1730            max_fee_per_gas: Some(1_000_000_000),
1731            max_priority_fee_per_gas: Some(1_000_000),
1732            gas_limit: Some(100_000),
1733            simulate: true,
1734        };
1735
1736        // eth_call → revert (RPC-level execution error)
1737        asserter.push_failure_msg("execution reverted");
1738
1739        let err = client
1740            .swap_payload(quote, &hints)
1741            .await
1742            .unwrap_err();
1743
1744        assert!(
1745            matches!(err, FyndError::SimulationFailed(_)),
1746            "expected SimulationFailed, got {err:?}"
1747        );
1748    }
1749
1750    // ========================================================================
1751    // execute_swap() dry-run tests
1752    // ========================================================================
1753
1754    /// Build a [`SignedSwap`] from a minimal [`Quote`] and a dummy transaction.
1755    ///
1756    /// Suitable for dry-run tests where neither the signature nor the transaction
1757    /// contents are validated on-chain.
1758    fn make_signed_swap() -> SignedSwap {
1759        use alloy::{
1760            eips::eip2930::AccessList,
1761            primitives::{Bytes as AlloyBytes, Signature, TxKind, U256},
1762        };
1763
1764        use crate::signing::FyndPayload;
1765
1766        let quote = make_order_quote();
1767        let tx = TxEip1559 {
1768            chain_id: 1,
1769            nonce: 1,
1770            max_fee_per_gas: 1_000_000_000,
1771            max_priority_fee_per_gas: 1_000_000,
1772            gas_limit: 100_000,
1773            to: TxKind::Call(Address::ZERO),
1774            value: U256::ZERO,
1775            input: AlloyBytes::new(),
1776            access_list: AccessList::default(),
1777        };
1778        let payload =
1779            SwapPayload::Fynd(Box::new(FyndPayload::new(quote, TypedTransaction::Eip1559(tx))));
1780        SignedSwap::assemble(payload, Signature::test_signature())
1781    }
1782
1783    #[tokio::test]
1784    async fn execute_dry_run_returns_settled_order_without_broadcast() {
1785        let sender = Address::with_last_byte(0xab);
1786        let (client, asserter) =
1787            make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1788
1789        // Encode 990_000 as ABI uint256 (32-byte big-endian).
1790        let mut amount_bytes = vec![0u8; 32];
1791        amount_bytes[24..32].copy_from_slice(&990_000u64.to_be_bytes());
1792        asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1793        asserter.push_success(&50_000u64); // estimate_gas response
1794
1795        let order = make_signed_swap();
1796        let opts =
1797            ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1798        let receipt = client
1799            .execute_swap(order, &opts)
1800            .await
1801            .expect("execute should succeed");
1802        let settled = receipt
1803            .await
1804            .expect("should resolve immediately");
1805
1806        assert_eq!(settled.settled_amount(), Some(&num_bigint::BigUint::from(990_000u64)),);
1807        let expected_gas_cost =
1808            num_bigint::BigUint::from(50_000u64) * num_bigint::BigUint::from(1_000_000_000u64);
1809        assert_eq!(settled.gas_cost(), &expected_gas_cost);
1810    }
1811
1812    #[tokio::test]
1813    async fn execute_dry_run_with_storage_overrides_succeeds() {
1814        let sender = Address::with_last_byte(0xab);
1815        let (client, asserter) =
1816            make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1817
1818        let mut overrides = StorageOverrides::default();
1819        overrides.insert(
1820            bytes::Bytes::copy_from_slice(&[0u8; 20]),
1821            bytes::Bytes::copy_from_slice(&[0u8; 32]),
1822            bytes::Bytes::copy_from_slice(&[1u8; 32]),
1823        );
1824
1825        let mut amount_bytes = vec![0u8; 32];
1826        amount_bytes[24..32].copy_from_slice(&100u64.to_be_bytes());
1827        asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1828        asserter.push_success(&21_000u64);
1829
1830        let order = make_signed_swap();
1831        let opts = ExecutionOptions {
1832            dry_run: true,
1833            storage_overrides: Some(overrides),
1834            fetch_revert_reason: false,
1835        };
1836        let receipt = client
1837            .execute_swap(order, &opts)
1838            .await
1839            .expect("execute with overrides should succeed");
1840        receipt.await.expect("should resolve");
1841    }
1842
1843    #[tokio::test]
1844    async fn execute_dry_run_returns_simulation_failed_on_call_error() {
1845        let sender = Address::with_last_byte(0xab);
1846        let (client, asserter) =
1847            make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1848
1849        asserter.push_failure_msg("execution reverted");
1850
1851        let order = make_signed_swap();
1852        let opts =
1853            ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1854        let result = client.execute_swap(order, &opts).await;
1855        let err = match result {
1856            Err(e) => e,
1857            Ok(_) => panic!("expected SimulationFailed error"),
1858        };
1859
1860        assert!(
1861            matches!(err, FyndError::SimulationFailed(_)),
1862            "expected SimulationFailed, got {err:?}"
1863        );
1864    }
1865
1866    #[tokio::test]
1867    async fn execute_dry_run_with_empty_return_data_has_no_settled_amount() {
1868        let sender = Address::with_last_byte(0xab);
1869        let (client, asserter) =
1870            make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1871
1872        asserter.push_success(&alloy::primitives::Bytes::new());
1873        asserter.push_success(&21_000u64);
1874
1875        let order = make_signed_swap();
1876        let opts =
1877            ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1878        let receipt = client
1879            .execute_swap(order, &opts)
1880            .await
1881            .expect("execute should succeed");
1882        let settled = receipt.await.expect("should resolve");
1883
1884        assert!(
1885            settled.settled_amount().is_none(),
1886            "empty return data should yield None settled_amount"
1887        );
1888    }
1889
1890    #[tokio::test]
1891    async fn swap_payload_returns_protocol_error_when_no_transaction() {
1892        use crate::types::{BackendKind, BlockInfo, QuoteStatus};
1893
1894        let sender = Address::with_last_byte(0xab);
1895        let (client, _asserter) =
1896            make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1897
1898        // Build a quote with no transaction (encoding_options not set in request)
1899        let quote = crate::types::Quote::new(
1900            "no-tx".to_string(),
1901            QuoteStatus::Success,
1902            BackendKind::Fynd,
1903            None,
1904            num_bigint::BigUint::from(1_000u64),
1905            num_bigint::BigUint::from(990u64),
1906            num_bigint::BigUint::from(50_000u64),
1907            num_bigint::BigUint::from(940u64),
1908            None,
1909            BlockInfo::new(1, "0xabc".to_string(), 0),
1910            bytes::Bytes::copy_from_slice(&[0xbb; 20]),
1911            bytes::Bytes::copy_from_slice(&[0xcc; 20]),
1912            None,
1913            None,
1914        );
1915        let hints = SigningHints {
1916            sender: Some(sender),
1917            nonce: Some(1),
1918            max_fee_per_gas: Some(1_000_000_000),
1919            max_priority_fee_per_gas: Some(1_000_000),
1920            gas_limit: Some(100_000),
1921            simulate: false,
1922        };
1923
1924        let err = client
1925            .swap_payload(quote, &hints)
1926            .await
1927            .unwrap_err();
1928
1929        assert!(
1930            matches!(err, FyndError::Protocol(_)),
1931            "expected Protocol error when quote has no transaction, got {err:?}"
1932        );
1933    }
1934
1935    // ========================================================================
1936    // Helper to build minimal QuoteParams
1937    // ========================================================================
1938
1939    fn make_quote_params() -> QuoteParams {
1940        use crate::types::{Order, OrderSide, QuoteOptions};
1941
1942        let token_in = bytes::Bytes::copy_from_slice(&[0xaa; 20]);
1943        let token_out = bytes::Bytes::copy_from_slice(&[0xbb; 20]);
1944        let sender = bytes::Bytes::copy_from_slice(&[0xcc; 20]);
1945
1946        let order = Order::new(
1947            token_in,
1948            token_out,
1949            num_bigint::BigUint::from(1_000_000u64),
1950            OrderSide::Sell,
1951            sender,
1952            None,
1953        );
1954
1955        QuoteParams::new(order, QuoteOptions::default())
1956    }
1957
1958    // ========================================================================
1959    // info() tests
1960    // ========================================================================
1961
1962    fn make_info_body() -> serde_json::Value {
1963        serde_json::json!({
1964            "chain_id": 1,
1965            "router_address": "0x0101010101010101010101010101010101010101",
1966            "permit2_address": "0x0202020202020202020202020202020202020202"
1967        })
1968    }
1969
1970    #[tokio::test]
1971    async fn info_fetches_and_caches() {
1972        use wiremock::{
1973            matchers::{method, path},
1974            Mock, MockServer, ResponseTemplate,
1975        };
1976
1977        let server = MockServer::start().await;
1978
1979        Mock::given(method("GET"))
1980            .and(path("/v1/info"))
1981            .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
1982            .expect(1) // only one HTTP hit expected despite two calls
1983            .mount(&server)
1984            .await;
1985
1986        let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1987
1988        let info1 = client
1989            .info()
1990            .await
1991            .expect("first info call should succeed");
1992        let info2 = client
1993            .info()
1994            .await
1995            .expect("second info call should use cache");
1996
1997        assert_eq!(info1.chain_id(), 1);
1998        assert_eq!(info2.chain_id(), 1);
1999        assert_eq!(info1.router_address().as_ref(), &[0x01u8; 20]);
2000        assert_eq!(info1.permit2_address().as_ref(), &[0x02u8; 20]);
2001        // MockServer verifies expect(1) on drop.
2002    }
2003
2004    // ========================================================================
2005    // approval() tests
2006    // ========================================================================
2007
2008    #[tokio::test]
2009    async fn approval_builds_correct_calldata() {
2010        use wiremock::{
2011            matchers::{method, path},
2012            Mock, MockServer, ResponseTemplate,
2013        };
2014
2015        let server = MockServer::start().await;
2016
2017        Mock::given(method("GET"))
2018            .and(path("/v1/info"))
2019            .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2020            .expect(1)
2021            .mount(&server)
2022            .await;
2023
2024        let sender = Address::with_last_byte(0xab);
2025        let (client, asserter) =
2026            make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2027
2028        // Hints provide nonce + fees so no RPC calls needed.
2029        let hints = SigningHints {
2030            sender: Some(sender),
2031            nonce: Some(3),
2032            max_fee_per_gas: Some(2_000_000_000),
2033            max_priority_fee_per_gas: Some(1_000_000),
2034            gas_limit: None, // should default to 65_000
2035            simulate: false,
2036        };
2037        // eth_estimateGas → 65_000
2038        asserter.push_success(&65_000u64);
2039
2040        let params = ApprovalParams::new(
2041            bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2042            num_bigint::BigUint::from(1_000_000u64),
2043            AllowanceCheck::Skip,
2044        );
2045
2046        let payload = client
2047            .approval(&params, &hints)
2048            .await
2049            .expect("approval should succeed")
2050            .expect("should build payload when AllowanceCheck::Skip");
2051
2052        // Verify function selector is approve(address,uint256) = 0x095ea7b3.
2053        let selector = &payload.tx().input[0..4];
2054        assert_eq!(selector, &[0x09, 0x5e, 0xa7, 0xb3]);
2055        assert_eq!(payload.tx().gas_limit, 65_000, "gas limit should come from eth_estimateGas");
2056        assert_eq!(payload.tx().nonce, 3);
2057    }
2058
2059    #[tokio::test]
2060    async fn approval_with_insufficient_allowance_returns_some() {
2061        use wiremock::{
2062            matchers::{method, path},
2063            Mock, MockServer, ResponseTemplate,
2064        };
2065
2066        let server = MockServer::start().await;
2067
2068        Mock::given(method("GET"))
2069            .and(path("/v1/info"))
2070            .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2071            .expect(1)
2072            .mount(&server)
2073            .await;
2074
2075        let sender = Address::with_last_byte(0xab);
2076        let (client, asserter) =
2077            make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2078
2079        let hints = SigningHints {
2080            sender: Some(sender),
2081            nonce: Some(0),
2082            max_fee_per_gas: Some(1_000_000_000),
2083            max_priority_fee_per_gas: Some(1_000_000),
2084            gas_limit: None,
2085            simulate: false,
2086        };
2087
2088        // Mock eth_call for allowance: return 0 (allowance insufficient).
2089        let zero_allowance = alloy::primitives::Bytes::copy_from_slice(&[0u8; 32]);
2090        asserter.push_success(&zero_allowance);
2091        // eth_estimateGas → 65_000
2092        asserter.push_success(&65_000u64);
2093
2094        let params = ApprovalParams::new(
2095            bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2096            num_bigint::BigUint::from(500_000u64),
2097            AllowanceCheck::AtLeast(num_bigint::BigUint::from(500_000u64)),
2098        );
2099
2100        let result = client
2101            .approval(&params, &hints)
2102            .await
2103            .expect("approval with allowance check should succeed");
2104
2105        assert!(result.is_some(), "zero allowance should return a payload");
2106    }
2107
2108    #[tokio::test]
2109    async fn approval_with_sufficient_allowance_returns_none() {
2110        use wiremock::{
2111            matchers::{method, path},
2112            Mock, MockServer, ResponseTemplate,
2113        };
2114
2115        let server = MockServer::start().await;
2116
2117        Mock::given(method("GET"))
2118            .and(path("/v1/info"))
2119            .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2120            .expect(1)
2121            .mount(&server)
2122            .await;
2123
2124        let sender = Address::with_last_byte(0xab);
2125        let (client, asserter) =
2126            make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2127
2128        let hints = SigningHints {
2129            sender: Some(sender),
2130            nonce: Some(0),
2131            max_fee_per_gas: Some(1_000_000_000),
2132            max_priority_fee_per_gas: Some(1_000_000),
2133            gas_limit: None,
2134            simulate: false,
2135        };
2136
2137        // Mock eth_call for allowance: return amount > requested (allowance sufficient).
2138        let mut allowance_bytes = [0u8; 32];
2139        // Encode 1_000_000 as big-endian uint256 (same as the amount we will request).
2140        allowance_bytes[24..32].copy_from_slice(&1_000_000u64.to_be_bytes());
2141        asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&allowance_bytes));
2142
2143        // Request 500_000, but allowance is 1_000_000 — sufficient.
2144        let params = ApprovalParams::new(
2145            bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2146            num_bigint::BigUint::from(500_000u64),
2147            AllowanceCheck::AtLeast(num_bigint::BigUint::from(500_000u64)),
2148        );
2149
2150        let result = client
2151            .approval(&params, &hints)
2152            .await
2153            .expect("approval with sufficient allowance check should succeed");
2154
2155        assert!(result.is_none(), "sufficient allowance should return None");
2156    }
2157
2158    // ========================================================================
2159    // execute_approval() tests
2160    // ========================================================================
2161
2162    fn make_signed_approval() -> crate::signing::SignedApproval {
2163        use alloy::primitives::{Signature, TxKind, U256};
2164
2165        use crate::signing::ApprovalPayload;
2166
2167        let tx = TxEip1559 {
2168            chain_id: 1,
2169            nonce: 0,
2170            max_fee_per_gas: 1_000_000_000,
2171            max_priority_fee_per_gas: 1_000_000,
2172            gas_limit: 65_000,
2173            to: TxKind::Call(Address::ZERO),
2174            value: U256::ZERO,
2175            input: AlloyBytes::from(vec![0x09, 0x5e, 0xa7, 0xb3]),
2176            access_list: AccessList::default(),
2177        };
2178        let payload = ApprovalPayload {
2179            tx,
2180            token: bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2181            spender: bytes::Bytes::copy_from_slice(&[0x01; 20]),
2182            amount: num_bigint::BigUint::from(1_000_000u64),
2183        };
2184        SignedApproval::assemble(payload, Signature::test_signature())
2185    }
2186
2187    #[tokio::test]
2188    async fn execute_approval_broadcasts_and_polls() {
2189        let sender = Address::with_last_byte(0xab);
2190        let (client, asserter) =
2191            make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
2192
2193        // send_raw_transaction response: tx hash
2194        let tx_hash = alloy::primitives::B256::repeat_byte(0xef);
2195        asserter.push_success(&tx_hash);
2196
2197        // get_transaction_receipt: first call returns null (pending), second returns receipt.
2198        asserter.push_success::<Option<()>>(&None);
2199        let receipt = alloy::rpc::types::TransactionReceipt {
2200            inner: alloy::consensus::ReceiptEnvelope::Eip1559(alloy::consensus::ReceiptWithBloom {
2201                receipt: alloy::consensus::Receipt::<alloy::primitives::Log> {
2202                    status: alloy::consensus::Eip658Value::Eip658(true),
2203                    cumulative_gas_used: 50_000,
2204                    logs: vec![],
2205                },
2206                logs_bloom: alloy::primitives::Bloom::default(),
2207            }),
2208            transaction_hash: tx_hash,
2209            transaction_index: None,
2210            block_hash: None,
2211            block_number: None,
2212            gas_used: 45_000,
2213            effective_gas_price: 1_500_000_000,
2214            blob_gas_used: None,
2215            blob_gas_price: None,
2216            from: Address::ZERO,
2217            to: None,
2218            contract_address: None,
2219        };
2220        asserter.push_success(&receipt);
2221
2222        let approval = make_signed_approval();
2223        let tx_receipt = client
2224            .execute_approval(approval)
2225            .await
2226            .expect("execute_approval should succeed");
2227
2228        let mined = tx_receipt
2229            .await
2230            .expect("receipt should resolve");
2231
2232        assert_eq!(mined.tx_hash(), tx_hash);
2233        let expected_cost =
2234            num_bigint::BigUint::from(45_000u64) * num_bigint::BigUint::from(1_500_000_000u64);
2235        assert_eq!(mined.gas_cost(), &expected_cost);
2236    }
2237}