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