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