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