Skip to main content

fynd_client/
client.rs

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