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