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#[derive(Clone)]
40pub struct RetryConfig {
41 max_attempts: u32,
42 initial_backoff: Duration,
43 max_backoff: Duration,
44}
45
46impl RetryConfig {
47 pub fn new(max_attempts: u32, initial_backoff: Duration, max_backoff: Duration) -> Self {
53 Self { max_attempts, initial_backoff, max_backoff }
54 }
55
56 pub fn max_attempts(&self) -> u32 {
58 self.max_attempts
59 }
60
61 pub fn initial_backoff(&self) -> Duration {
63 self.initial_backoff
64 }
65
66 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#[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 pub fn with_sender(mut self, sender: Address) -> Self {
106 self.sender = Some(sender);
107 self
108 }
109
110 pub fn with_nonce(mut self, nonce: u64) -> Self {
112 self.nonce = Some(nonce);
113 self
114 }
115
116 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 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 pub fn with_gas_limit(mut self, gas_limit: u64) -> Self {
132 self.gas_limit = Some(gas_limit);
133 self
134 }
135
136 pub fn with_simulate(mut self, simulate: bool) -> Self {
139 self.simulate = simulate;
140 self
141 }
142
143 pub fn sender(&self) -> Option<Address> {
145 self.sender
146 }
147
148 pub fn nonce(&self) -> Option<u64> {
150 self.nonce
151 }
152
153 pub fn max_fee_per_gas(&self) -> Option<u128> {
155 self.max_fee_per_gas
156 }
157
158 pub fn max_priority_fee_per_gas(&self) -> Option<u128> {
160 self.max_priority_fee_per_gas
161 }
162
163 pub fn gas_limit(&self) -> Option<u64> {
165 self.gas_limit
166 }
167
168 pub fn simulate(&self) -> bool {
170 self.simulate
171 }
172}
173
174#[derive(Clone, Default)]
197pub struct StorageOverrides {
198 slots: HashMap<Bytes, HashMap<Bytes, Bytes>>,
200 balances: HashMap<Bytes, BigUint>,
202}
203
204impl StorageOverrides {
205 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 pub fn set_native_balance(&mut self, address: Bytes, wei: BigUint) {
223 self.balances.insert(address, wei);
224 }
225
226 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#[derive(Clone)]
274pub struct ExecutionOptions {
275 pub dry_run: bool,
280 pub storage_overrides: Option<StorageOverrides>,
283 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#[derive(Clone)]
303pub enum AllowanceCheck {
304 Skip,
306 AtLeast(BigUint),
312}
313
314#[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 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 pub fn with_transfer_type(mut self, transfer_type: UserTransferType) -> Self {
343 self.transfer_type = transfer_type;
344 self
345 }
346}
347
348mod 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
361const SUPPORTED_CHAINS: [(&str, u64); 7] = [
367 ("ethereum", 1),
368 ("base", 8453),
369 ("arbitrum", 42161),
370 ("bsc", 56),
371 ("polygon", 137),
372 ("unichain", 130),
373 ("robinhood", 4663),
374];
375
376fn chain_id_for_slug(chain: &str) -> Result<u64, FyndError> {
380 for (slug, id) in SUPPORTED_CHAINS {
381 if slug == chain {
382 return Ok(id);
383 }
384 }
385 let supported: Vec<&str> = SUPPORTED_CHAINS
386 .iter()
387 .map(|(slug, _)| *slug)
388 .collect();
389 Err(FyndError::Config(format!(
390 "unsupported chain '{chain}'; expected one of: {}",
391 supported.join(", ")
392 )))
393}
394
395#[derive(Clone, Default)]
400pub struct HostedConfig {
401 pub api_key: Option<String>,
404 pub chain: Option<String>,
406}
407
408pub struct FyndClientBuilder {
420 base_url: String,
421 timeout: Duration,
422 retry: RetryConfig,
423 rpc_url: Option<String>,
424 submit_url: Option<String>,
425 sender: Option<Address>,
426 hosted: HostedConfig,
427}
428
429impl FyndClientBuilder {
430 pub fn new(base_url: impl Into<String>) -> Self {
439 Self {
440 base_url: base_url.into(),
441 timeout: Duration::from_secs(30),
442 retry: RetryConfig::default(),
443 rpc_url: None,
444 submit_url: None,
445 sender: None,
446 hosted: HostedConfig::default(),
447 }
448 }
449
450 pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
456 self.hosted.api_key = Some(api_key.into());
457 self
458 }
459
460 pub fn with_chain(mut self, chain: impl Into<String>) -> Self {
467 self.hosted.chain = Some(chain.into());
468 self
469 }
470
471 pub fn with_rpc_url(mut self, rpc_url: impl Into<String>) -> Self {
476 self.rpc_url = Some(rpc_url.into());
477 self
478 }
479
480 pub fn with_timeout(mut self, timeout: Duration) -> Self {
482 self.timeout = timeout;
483 self
484 }
485
486 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
488 self.retry = retry;
489 self
490 }
491
492 pub fn with_submit_url(mut self, url: impl Into<String>) -> Self {
496 self.submit_url = Some(url.into());
497 self
498 }
499
500 pub fn with_sender(mut self, sender: Address) -> Self {
502 self.sender = Some(sender);
503 self
504 }
505
506 pub fn build_quote_only(self) -> Result<FyndClient, FyndError> {
517 let parsed_base = self
518 .base_url
519 .parse::<reqwest::Url>()
520 .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
521 let scheme = parsed_base.scheme();
522 if scheme != "http" && scheme != "https" {
523 return Err(FyndError::Config(format!(
524 "base URL must use http or https scheme, got '{scheme}'"
525 )));
526 }
527
528 let chain_id = match &self.hosted.chain {
532 Some(chain) => chain_id_for_slug(chain)?,
533 None => 1,
534 };
535
536 let provider = ProviderBuilder::default().connect_http(parsed_base.clone());
539 let submit_provider = ProviderBuilder::default().connect_http(parsed_base);
540
541 let http = HttpClient::builder()
542 .timeout(self.timeout)
543 .build()
544 .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
545
546 Ok(FyndClient {
547 http,
548 base_url: self.base_url,
549 retry: self.retry,
550 chain_id,
551 default_sender: self.sender,
552 provider,
553 submit_provider,
554 hosted: self.hosted,
555 info_cache: tokio::sync::OnceCell::new(),
556 })
557 }
558
559 pub async fn build(self) -> Result<FyndClient, FyndError> {
566 let expected_chain_id = self
568 .hosted
569 .chain
570 .as_deref()
571 .map(chain_id_for_slug)
572 .transpose()?;
573
574 let parsed_base = self
576 .base_url
577 .parse::<reqwest::Url>()
578 .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
579 let scheme = parsed_base.scheme();
580 if scheme != "http" && scheme != "https" {
581 return Err(FyndError::Config(format!(
582 "base URL must use http or https scheme, got '{scheme}'"
583 )));
584 }
585
586 let rpc_url_str = self
588 .rpc_url
589 .ok_or_else(|| FyndError::Config("rpc_url is required: call with_rpc_url()".into()))?;
590 let rpc_url = rpc_url_str
591 .parse::<reqwest::Url>()
592 .map_err(|e| FyndError::Config(format!("invalid RPC URL: {e}")))?;
593 let provider = ProviderBuilder::default().connect_http(rpc_url);
594
595 let submit_url_str = self
596 .submit_url
597 .as_deref()
598 .unwrap_or(&rpc_url_str);
599 let submit_url = submit_url_str
600 .parse::<reqwest::Url>()
601 .map_err(|e| FyndError::Config(format!("invalid submit URL: {e}")))?;
602 let submit_provider = ProviderBuilder::default().connect_http(submit_url);
603
604 let chain_id = provider
606 .get_chain_id()
607 .await
608 .map_err(|e| FyndError::Config(format!("failed to fetch chain_id from RPC: {e}")))?;
609
610 if let Some(expected) = expected_chain_id {
613 if expected != chain_id {
614 return Err(FyndError::Config(format!(
615 "chain mismatch: with_chain() implies chain_id {expected}, but the RPC node \
616 reports {chain_id}"
617 )));
618 }
619 }
620
621 let http = HttpClient::builder()
623 .timeout(self.timeout)
624 .build()
625 .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
626
627 Ok(FyndClient {
628 http,
629 base_url: self.base_url,
630 retry: self.retry,
631 chain_id,
632 default_sender: self.sender,
633 provider,
634 submit_provider,
635 hosted: self.hosted,
636 info_cache: tokio::sync::OnceCell::new(),
637 })
638 }
639}
640
641pub struct FyndClient<P = RootProvider<Ethereum>>
652where
653 P: Provider<Ethereum> + Clone + Send + Sync + 'static,
654{
655 http: HttpClient,
656 base_url: String,
657 retry: RetryConfig,
658 chain_id: u64,
659 default_sender: Option<Address>,
660 provider: P,
661 submit_provider: P,
662 hosted: HostedConfig,
663 info_cache: tokio::sync::OnceCell<InstanceInfo>,
664}
665
666impl<P> FyndClient<P>
667where
668 P: Provider<Ethereum> + Clone + Send + Sync + 'static,
669{
670 #[doc(hidden)]
674 #[allow(clippy::too_many_arguments)]
675 pub fn new_with_providers(
676 http: HttpClient,
677 base_url: String,
678 retry: RetryConfig,
679 chain_id: u64,
680 default_sender: Option<Address>,
681 provider: P,
682 submit_provider: P,
683 hosted: HostedConfig,
684 ) -> Self {
685 Self {
686 http,
687 base_url,
688 retry,
689 chain_id,
690 default_sender,
691 provider,
692 submit_provider,
693 hosted,
694 info_cache: tokio::sync::OnceCell::new(),
695 }
696 }
697
698 fn endpoint(&self, path: &str) -> String {
700 match &self.hosted.chain {
701 Some(chain) => format!("{}/v1/{chain}/{path}", self.base_url),
702 None => format!("{}/v1/{path}", self.base_url),
703 }
704 }
705
706 fn authorized(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
713 match &self.hosted.api_key {
714 Some(api_key) => request.header(reqwest::header::AUTHORIZATION, api_key),
715 None => request,
716 }
717 }
718
719 pub async fn quote(&self, params: QuoteParams) -> Result<Quote, FyndError> {
726 let token_out = params.order.token_out().clone();
727 let receiver = params
728 .order
729 .receiver()
730 .unwrap_or_else(|| params.order.sender())
731 .clone();
732 let dto_request = mapping::quote_params_to_dto(params)?;
733
734 let mut delay = self.retry.initial_backoff;
735 for attempt in 0..self.retry.max_attempts {
736 match self
737 .request_quote(&dto_request, token_out.clone(), receiver.clone())
738 .await
739 {
740 Ok(quote) => return Ok(quote),
741 Err(e) if e.is_retryable() && attempt + 1 < self.retry.max_attempts => {
742 tracing::debug!(attempt, "quote request failed, retrying");
743 tokio::time::sleep(delay).await;
744 delay = (delay * 2).min(self.retry.max_backoff);
745 }
746 Err(e) => return Err(e),
747 }
748 }
749 Err(FyndError::Protocol("retry loop exhausted without result".into()))
750 }
751
752 async fn request_quote(
753 &self,
754 dto_request: &fynd_rpc_types::QuoteRequest,
755 token_out: Bytes,
756 receiver: Bytes,
757 ) -> Result<Quote, FyndError> {
758 let url = self.endpoint("quote");
759 let response = self
760 .authorized(self.http.post(&url))
761 .json(dto_request)
762 .send()
763 .await?;
764 if !response.status().is_success() {
765 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
766 return Err(mapping::dto_error_to_fynd(dto_err));
767 }
768 let dto_quote: fynd_rpc_types::Quote = response.json().await?;
769 mapping::map_quote_response(dto_quote, vec![(token_out, receiver)])?
770 .into_iter()
771 .next()
772 .ok_or_else(|| FyndError::Protocol("server returned empty quote list".into()))
773 }
774
775 pub async fn batch_quote(&self, params: BatchQuoteParams) -> Result<Vec<Quote>, FyndError> {
783 let (dto_request, order_meta) = mapping::batch_quote_params_to_dto(params)?;
784
785 let mut delay = self.retry.initial_backoff;
786 for attempt in 0..self.retry.max_attempts {
787 match self
788 .request_batch_quote(&dto_request, order_meta.clone())
789 .await
790 {
791 Ok(quotes) => return Ok(quotes),
792 Err(e) if e.is_retryable() && attempt + 1 < self.retry.max_attempts => {
793 tracing::debug!(attempt, "batch_quote request failed, retrying");
794 tokio::time::sleep(delay).await;
795 delay = (delay * 2).min(self.retry.max_backoff);
796 }
797 Err(e) => return Err(e),
798 }
799 }
800 Err(FyndError::Protocol("retry loop exhausted without result".into()))
801 }
802
803 async fn request_batch_quote(
804 &self,
805 dto_request: &fynd_rpc_types::QuoteRequest,
806 order_meta: Vec<(Bytes, Bytes)>,
807 ) -> Result<Vec<Quote>, FyndError> {
808 let url = self.endpoint("quote");
809 let response = self
810 .authorized(self.http.post(&url))
811 .json(dto_request)
812 .send()
813 .await?;
814 if !response.status().is_success() {
815 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
816 return Err(mapping::dto_error_to_fynd(dto_err));
817 }
818 let dto_quote: fynd_rpc_types::Quote = response.json().await?;
819 mapping::map_quote_response(dto_quote, order_meta)
820 }
821
822 pub async fn health(&self) -> Result<HealthStatus, FyndError> {
824 let url = self.endpoint("health");
825 let response = self
826 .authorized(self.http.get(&url))
827 .send()
828 .await?;
829 let status = response.status();
830 let body = response.text().await?;
831 if let Ok(dh) = serde_json::from_str::<fynd_rpc_types::HealthStatus>(&body) {
834 return Ok(HealthStatus::from(dh));
835 }
836 if let Ok(dto_err) = serde_json::from_str::<fynd_rpc_types::ErrorResponse>(&body) {
837 return Err(mapping::dto_error_to_fynd(dto_err));
838 }
839 Err(FyndError::Protocol(format!("unexpected health response ({status}): {body}")))
840 }
841
842 pub async fn swap_payload(
854 &self,
855 quote: Quote,
856 hints: &SigningHints,
857 ) -> Result<SwapPayload, FyndError> {
858 match quote.backend() {
859 BackendKind::Fynd => {
860 self.fynd_swap_payload(quote, hints)
861 .await
862 }
863 BackendKind::Turbine => {
864 Err(FyndError::Protocol("Turbine signing not yet implemented".into()))
865 }
866 }
867 }
868
869 async fn fynd_swap_payload(
870 &self,
871 quote: Quote,
872 hints: &SigningHints,
873 ) -> Result<SwapPayload, FyndError> {
874 let sender = hints
876 .sender()
877 .or(self.default_sender)
878 .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
879
880 let nonce = match hints.nonce() {
882 Some(n) => n,
883 None => self
884 .provider
885 .get_transaction_count(sender)
886 .await
887 .map_err(FyndError::Provider)?,
888 };
889
890 let (max_fee_per_gas, max_priority_fee_per_gas) =
892 match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
893 (Some(mf), Some(mp)) => (mf, mp),
894 (mf, mp) => {
895 let est = self
896 .provider
897 .estimate_eip1559_fees()
898 .await
899 .map_err(FyndError::Provider)?;
900 (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
901 }
902 };
903
904 let tx_data = quote.transaction().ok_or_else(|| {
905 FyndError::Protocol(
906 "quote has no calldata; set encoding_options in QuoteOptions".into(),
907 )
908 })?;
909 let to_addr = mapping::bytes_to_alloy_address(tx_data.to())?;
910 let value = mapping::biguint_to_u256(tx_data.value());
911 let input = AlloyBytes::from(tx_data.data().to_vec());
912
913 let gas_limit = match hints.gas_limit() {
917 Some(g) => g,
918 None => {
919 let req = alloy::rpc::types::TransactionRequest::default()
920 .from(sender)
921 .to(to_addr)
922 .value(value)
923 .input(input.clone().into());
924 self.provider
925 .estimate_gas(req)
926 .await
927 .map_err(FyndError::Provider)?
928 }
929 };
930
931 let tx_eip1559 = TxEip1559 {
932 chain_id: self.chain_id,
933 nonce,
934 max_fee_per_gas,
935 max_priority_fee_per_gas,
936 gas_limit,
937 to: TxKind::Call(to_addr),
938 value,
939 input,
940 access_list: AccessList::default(),
941 };
942
943 if hints.simulate() {
945 let req = alloy::rpc::types::TransactionRequest::from_transaction_with_sender(
946 tx_eip1559.clone(),
947 sender,
948 );
949 self.provider
950 .call(req)
951 .await
952 .map_err(|e| {
953 FyndError::SimulationFailed(format!("transaction simulation failed: {e}"))
954 })?;
955 }
956
957 let tx = TypedTransaction::Eip1559(tx_eip1559);
958 Ok(SwapPayload::Fynd(Box::new(FyndPayload::new(quote, tx))))
959 }
960
961 pub async fn execute_swap(
972 &self,
973 order: SignedSwap,
974 options: &ExecutionOptions,
975 ) -> Result<ExecutionReceipt, FyndError> {
976 let (payload, signature) = order.into_parts();
977 let (quote, tx) = payload.into_fynd_parts()?;
978
979 let TypedTransaction::Eip1559(tx_eip1559) = tx else {
980 return Err(FyndError::Protocol(
981 "only EIP-1559 transactions are supported for execution".into(),
982 ));
983 };
984
985 if options.dry_run {
986 return self
987 .dry_run_execute(tx_eip1559, options)
988 .await;
989 }
990
991 let tx_hash = self
992 .send_raw(tx_eip1559.clone(), signature)
993 .await?;
994
995 let token_out_addr = mapping::bytes_to_alloy_address(quote.token_out())?;
996 let receiver_addr = mapping::bytes_to_alloy_address(quote.receiver())?;
997 let provider = self.submit_provider.clone();
998 let fetch_revert = options.fetch_revert_reason;
999 let fallback_to = match tx_eip1559.to {
1003 TxKind::Call(addr) => addr,
1004 TxKind::Create => Address::ZERO,
1005 };
1006 let fallback_req = TransactionRequest::default()
1007 .to(fallback_to)
1008 .value(tx_eip1559.value)
1009 .input(tx_eip1559.input.clone().into());
1010
1011 Ok(ExecutionReceipt::Transaction(Box::pin(async move {
1012 loop {
1013 match provider
1014 .get_transaction_receipt(tx_hash)
1015 .await
1016 .map_err(FyndError::Provider)?
1017 {
1018 Some(receipt) => {
1019 if !receipt.status() {
1020 let reason = if fetch_revert {
1021 let trace: Result<serde_json::Value, _> = provider
1023 .raw_request(
1024 std::borrow::Cow::Borrowed("debug_traceTransaction"),
1025 (tx_hash, serde_json::json!({})),
1026 )
1027 .await;
1028 match trace {
1029 Ok(t) => {
1030 let hex_str = t
1031 .get("returnValue")
1032 .and_then(|v| v.as_str())
1033 .unwrap_or("");
1034 match alloy::primitives::hex::decode(
1035 hex_str.trim_start_matches("0x"),
1036 ) {
1037 Ok(b) => decode_revert_bytes(&b),
1038 Err(_) => format!(
1039 "{tx_hash:#x} reverted (return value: {hex_str})"
1040 ),
1041 }
1042 }
1043 Err(_) => {
1044 tracing::warn!(
1045 tx = ?tx_hash,
1046 "debug_traceTransaction unavailable; replaying via \
1047 eth_call — block state may differ"
1048 );
1049 match provider.call(fallback_req).await {
1050 Err(e) => e.to_string(),
1051 Ok(_) => {
1052 format!("{tx_hash:#x} reverted (no reason)")
1053 }
1054 }
1055 }
1056 }
1057 } else {
1058 format!("{tx_hash:#x}")
1059 };
1060 return Err(FyndError::TransactionReverted(reason));
1061 }
1062 let settled_amount =
1063 compute_settled_amount(&receipt, &token_out_addr, &receiver_addr);
1064 let gas_cost = BigUint::from(receipt.gas_used) *
1065 BigUint::from(receipt.effective_gas_price);
1066 return Ok(SettledOrder::new(Some(tx_hash), settled_amount, gas_cost));
1067 }
1068 None => tokio::time::sleep(Duration::from_secs(2)).await,
1069 }
1070 }
1071 })))
1072 }
1073
1074 pub async fn info(&self) -> Result<&InstanceInfo, FyndError> {
1079 self.info_cache
1080 .get_or_try_init(|| self.fetch_info())
1081 .await
1082 }
1083
1084 async fn fetch_info(&self) -> Result<InstanceInfo, FyndError> {
1085 let url = self.endpoint("info");
1086 let response = self
1087 .authorized(self.http.get(&url))
1088 .send()
1089 .await?;
1090 if !response.status().is_success() {
1091 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
1092 return Err(mapping::dto_error_to_fynd(dto_err));
1093 }
1094 let dto_info: fynd_rpc_types::InstanceInfo = response.json().await?;
1095 dto_info.try_into()
1096 }
1097
1098 pub async fn approval(
1112 &self,
1113 params: &ApprovalParams,
1114 hints: &SigningHints,
1115 ) -> Result<Option<ApprovalPayload>, FyndError> {
1116 use alloy::sol_types::SolCall;
1117
1118 let info = self.info().await?;
1119 let spender_addr = match params.transfer_type {
1120 UserTransferType::TransferFrom => {
1121 let router_address = info.router_address().ok_or_else(|| {
1122 FyndError::Config(
1123 "server has no router_address; encoding is unavailable on this chain"
1124 .into(),
1125 )
1126 })?;
1127 mapping::bytes_to_alloy_address(router_address)?
1128 }
1129 UserTransferType::TransferFromPermit2 => {
1130 mapping::bytes_to_alloy_address(info.permit2_address())?
1131 }
1132 UserTransferType::UseVaultsFunds => return Ok(None),
1133 };
1134
1135 let sender = hints
1136 .sender()
1137 .or(self.default_sender)
1138 .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
1139
1140 let token_addr = mapping::bytes_to_alloy_address(¶ms.token)?;
1141 let amount_u256 = mapping::biguint_to_u256(¶ms.amount);
1142
1143 if let AllowanceCheck::AtLeast(min) = ¶ms.allowance_check {
1145 let call_data =
1146 erc20::allowanceCall { owner: sender, spender: spender_addr }.abi_encode();
1147 let req = alloy::rpc::types::TransactionRequest {
1148 to: Some(alloy::primitives::TxKind::Call(token_addr)),
1149 input: alloy::rpc::types::TransactionInput::new(AlloyBytes::from(call_data)),
1150 ..Default::default()
1151 };
1152 let result = self
1153 .provider
1154 .call(req)
1155 .await
1156 .map_err(|e| FyndError::Protocol(format!("allowance call failed: {e}")))?;
1157 let current_allowance = if result.len() >= 32 {
1158 alloy::primitives::U256::from_be_slice(&result[0..32])
1159 } else {
1160 alloy::primitives::U256::ZERO
1161 };
1162 if current_allowance >= mapping::biguint_to_u256(min) {
1163 return Ok(None);
1164 }
1165 }
1166
1167 let nonce = match hints.nonce() {
1169 Some(n) => n,
1170 None => self
1171 .provider
1172 .get_transaction_count(sender)
1173 .await
1174 .map_err(FyndError::Provider)?,
1175 };
1176
1177 let (max_fee_per_gas, max_priority_fee_per_gas) =
1179 match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
1180 (Some(mf), Some(mp)) => (mf, mp),
1181 (mf, mp) => {
1182 let est = self
1183 .provider
1184 .estimate_eip1559_fees()
1185 .await
1186 .map_err(FyndError::Provider)?;
1187 (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
1188 }
1189 };
1190
1191 let calldata =
1192 erc20::approveCall { spender: spender_addr, amount: amount_u256 }.abi_encode();
1193
1194 let gas_limit = match hints.gas_limit() {
1196 Some(g) => g,
1197 None => {
1198 let req = alloy::rpc::types::TransactionRequest::default()
1199 .from(sender)
1200 .to(token_addr)
1201 .input(AlloyBytes::from(calldata.clone()).into());
1202 self.provider
1203 .estimate_gas(req)
1204 .await
1205 .map_err(FyndError::Provider)?
1206 }
1207 };
1208
1209 let tx = TxEip1559 {
1210 chain_id: self.chain_id,
1211 nonce,
1212 max_fee_per_gas,
1213 max_priority_fee_per_gas,
1214 gas_limit,
1215 to: alloy::primitives::TxKind::Call(token_addr),
1216 value: alloy::primitives::U256::ZERO,
1217 input: AlloyBytes::from(calldata),
1218 access_list: alloy::eips::eip2930::AccessList::default(),
1219 };
1220
1221 let spender = bytes::Bytes::copy_from_slice(spender_addr.as_slice());
1222 Ok(Some(ApprovalPayload {
1223 tx,
1224 token: params.token.clone(),
1225 spender,
1226 amount: params.amount.clone(),
1227 }))
1228 }
1229
1230 pub async fn execute_approval(&self, approval: SignedApproval) -> Result<TxReceipt, FyndError> {
1236 let (payload, signature) = approval.into_parts();
1237 let fallback_req = TransactionRequest::default()
1238 .to(mapping::bytes_to_alloy_address(&payload.token)?)
1239 .input(payload.tx.input.clone().into());
1240 let tx_hash = self
1241 .send_raw(payload.tx, signature)
1242 .await?;
1243 let provider = self.submit_provider.clone();
1244
1245 Ok(TxReceipt::Pending(Box::pin(async move {
1246 loop {
1247 match provider
1248 .get_transaction_receipt(tx_hash)
1249 .await
1250 .map_err(FyndError::Provider)?
1251 {
1252 Some(receipt) => {
1253 if !receipt.status() {
1254 let trace: Result<serde_json::Value, _> = provider
1255 .raw_request(
1256 std::borrow::Cow::Borrowed("debug_traceTransaction"),
1257 (tx_hash, serde_json::json!({})),
1258 )
1259 .await;
1260 let reason = match trace {
1261 Ok(t) => {
1262 let hex_str = t
1263 .get("returnValue")
1264 .and_then(|v| v.as_str())
1265 .unwrap_or("");
1266 match alloy::primitives::hex::decode(
1267 hex_str.trim_start_matches("0x"),
1268 ) {
1269 Ok(b) => decode_revert_bytes(&b),
1270 Err(_) => format!(
1271 "{tx_hash:#x} reverted (return value: {hex_str})"
1272 ),
1273 }
1274 }
1275 Err(_) => {
1276 tracing::warn!(
1277 tx = ?tx_hash,
1278 "debug_traceTransaction unavailable; replaying via \
1279 eth_call — block state may differ"
1280 );
1281 match provider.call(fallback_req).await {
1282 Err(e) => e.to_string(),
1283 Ok(_) => format!("{tx_hash:#x} reverted (no reason)"),
1284 }
1285 }
1286 };
1287 return Err(FyndError::TransactionReverted(reason));
1288 }
1289 let gas_cost = BigUint::from(receipt.gas_used) *
1290 BigUint::from(receipt.effective_gas_price);
1291 return Ok(MinedTx::new(tx_hash, gas_cost));
1292 }
1293 None => tokio::time::sleep(Duration::from_secs(2)).await,
1294 }
1295 }
1296 })))
1297 }
1298
1299 async fn send_raw(
1301 &self,
1302 tx: TxEip1559,
1303 signature: alloy::primitives::Signature,
1304 ) -> Result<B256, FyndError> {
1305 use alloy::eips::eip2718::Encodable2718;
1306 let envelope = TypedTransaction::Eip1559(tx).into_envelope(signature);
1307 let raw = envelope.encoded_2718();
1308 let pending = self
1309 .submit_provider
1310 .send_raw_transaction(&raw)
1311 .await
1312 .map_err(FyndError::Provider)?;
1313 Ok(*pending.tx_hash())
1314 }
1315
1316 async fn dry_run_execute(
1317 &self,
1318 tx_eip1559: TxEip1559,
1319 options: &ExecutionOptions,
1320 ) -> Result<ExecutionReceipt, FyndError> {
1321 let mut req: TransactionRequest = tx_eip1559.clone().into();
1322 if let Some(sender) = self.default_sender {
1323 req.from = Some(sender);
1324 }
1325 let overrides = options
1326 .storage_overrides
1327 .as_ref()
1328 .map(storage_overrides_to_alloy)
1329 .transpose()?;
1330
1331 let return_data = self
1332 .provider
1333 .call(req.clone())
1334 .overrides_opt(overrides.clone())
1335 .await
1336 .map_err(|e| FyndError::SimulationFailed(format!("dry run simulation failed: {e}")))?;
1337
1338 let gas_used = self
1339 .provider
1340 .estimate_gas(req)
1341 .overrides_opt(overrides)
1342 .await
1343 .map_err(|e| {
1344 FyndError::SimulationFailed(format!("dry run gas estimation failed: {e}"))
1345 })?;
1346
1347 let settled_amount = if return_data.len() >= 32 {
1348 Some(BigUint::from_bytes_be(&return_data[0..32]))
1349 } else {
1350 None
1351 };
1352 let gas_cost = BigUint::from(gas_used) * BigUint::from(tx_eip1559.max_fee_per_gas);
1353 let settled = SettledOrder::new(None, settled_amount, gas_cost);
1354
1355 Ok(ExecutionReceipt::Transaction(Box::pin(async move { Ok(settled) })))
1356 }
1357}
1358
1359fn decode_revert_bytes(data: &[u8]) -> String {
1364 const SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0];
1366 if data.len() >= 68 && data[..4] == SELECTOR {
1367 let str_len = u64::from_be_bytes(
1368 data[60..68]
1369 .try_into()
1370 .unwrap_or([0u8; 8]),
1371 ) as usize;
1372 if data.len() >= 68 + str_len {
1373 if let Ok(s) = std::str::from_utf8(&data[68..68 + str_len]) {
1374 return s.to_owned();
1375 }
1376 }
1377 }
1378 if data.is_empty() {
1379 "empty revert data".to_owned()
1380 } else {
1381 format!("0x{}", alloy::primitives::hex::encode(data))
1382 }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387 use std::time::Duration;
1388
1389 use super::*;
1390
1391 #[test]
1392 fn retry_config_default_values() {
1393 let config = RetryConfig::default();
1394 assert_eq!(config.max_attempts(), 3);
1395 assert_eq!(config.initial_backoff(), Duration::from_millis(100));
1396 assert_eq!(config.max_backoff(), Duration::from_secs(2));
1397 }
1398
1399 #[test]
1400 fn signing_hints_default_all_none_and_no_simulate() {
1401 let hints = SigningHints::default();
1402 assert!(hints.sender().is_none());
1403 assert!(hints.nonce().is_none());
1404 assert!(!hints.simulate());
1405 }
1406
1407 fn make_test_client(
1416 base_url: String,
1417 retry: RetryConfig,
1418 default_sender: Option<Address>,
1419 ) -> (FyndClient<alloy::providers::RootProvider<Ethereum>>, alloy::providers::mock::Asserter)
1420 {
1421 make_hosted_test_client(base_url, retry, default_sender, HostedConfig::default())
1422 }
1423
1424 fn make_hosted_test_client(
1427 base_url: String,
1428 retry: RetryConfig,
1429 default_sender: Option<Address>,
1430 hosted: HostedConfig,
1431 ) -> (FyndClient<alloy::providers::RootProvider<Ethereum>>, alloy::providers::mock::Asserter)
1432 {
1433 use alloy::providers::{mock::Asserter, ProviderBuilder};
1434
1435 let asserter = Asserter::new();
1436 let provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1437 let submit_provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1438
1439 let http = HttpClient::builder()
1440 .timeout(Duration::from_secs(5))
1441 .build()
1442 .expect("reqwest client");
1443
1444 let client = FyndClient::new_with_providers(
1445 http,
1446 base_url,
1447 retry,
1448 1,
1449 default_sender,
1450 provider,
1451 submit_provider,
1452 hosted,
1453 );
1454
1455 (client, asserter)
1456 }
1457
1458 fn make_order_quote() -> crate::types::Quote {
1460 use num_bigint::BigUint;
1461
1462 use crate::types::{BackendKind, BlockInfo, QuoteStatus, Transaction};
1463
1464 let tx = Transaction::new(
1465 bytes::Bytes::copy_from_slice(&[0x01; 20]),
1466 BigUint::ZERO,
1467 vec![0x12, 0x34],
1468 );
1469
1470 crate::types::Quote::new(
1471 "test-order-id".to_string(),
1472 QuoteStatus::Success,
1473 BackendKind::Fynd,
1474 None,
1475 BigUint::from(1_000_000u64),
1476 BigUint::from(990_000u64),
1477 BigUint::from(50_000u64),
1478 BigUint::from(940_000u64),
1479 Some(10),
1480 BlockInfo::new(1_234_567, "0xabcdef".to_string(), 1_700_000_000),
1481 bytes::Bytes::copy_from_slice(&[0xbb; 20]),
1482 bytes::Bytes::copy_from_slice(&[0xcc; 20]),
1483 Some(tx),
1484 None,
1485 )
1486 }
1487
1488 #[tokio::test]
1493 async fn quote_returns_parsed_quote_on_success() {
1494 use wiremock::{
1495 matchers::{method, path},
1496 Mock, MockServer, ResponseTemplate,
1497 };
1498
1499 let server = MockServer::start().await;
1500 let body = serde_json::json!({
1501 "orders": [{
1502 "order_id": "abc-123",
1503 "status": "success",
1504 "amount_in": "1000000",
1505 "amount_out": "990000",
1506 "gas_estimate": "50000",
1507 "amount_out_net_gas": "940000",
1508 "price_impact_bps": 10,
1509 "block": {
1510 "number": 1234567,
1511 "hash": "0xabcdef",
1512 "timestamp": 1700000000
1513 }
1514 }],
1515 "total_gas_estimate": "50000",
1516 "solve_time_ms": 42
1517 });
1518
1519 Mock::given(method("POST"))
1520 .and(path("/v1/quote"))
1521 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1522 .expect(1)
1523 .mount(&server)
1524 .await;
1525
1526 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1527
1528 let params = make_quote_params();
1529 let quote = client
1530 .quote(params)
1531 .await
1532 .expect("quote should succeed");
1533
1534 assert_eq!(quote.order_id(), "abc-123");
1535 assert_eq!(quote.amount_out(), &num_bigint::BigUint::from(990_000u64));
1536 }
1537
1538 #[tokio::test]
1539 async fn quote_returns_api_error_on_non_retryable_server_error() {
1540 use wiremock::{
1541 matchers::{method, path},
1542 Mock, MockServer, ResponseTemplate,
1543 };
1544
1545 use crate::error::ErrorCode;
1546
1547 let server = MockServer::start().await;
1548
1549 Mock::given(method("POST"))
1550 .and(path("/v1/quote"))
1551 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1552 "error": "bad input",
1553 "code": "BAD_REQUEST"
1554 })))
1555 .expect(1)
1556 .mount(&server)
1557 .await;
1558
1559 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1560
1561 let err = client
1562 .quote(make_quote_params())
1563 .await
1564 .unwrap_err();
1565 assert!(
1566 matches!(err, FyndError::Api { code: ErrorCode::BadRequest, .. }),
1567 "expected BadRequest, got {err:?}"
1568 );
1569 }
1570
1571 #[tokio::test]
1572 async fn quote_retries_on_retryable_error_then_succeeds() {
1573 use wiremock::{
1574 matchers::{method, path},
1575 Mock, MockServer, ResponseTemplate,
1576 };
1577
1578 let server = MockServer::start().await;
1579
1580 Mock::given(method("POST"))
1582 .and(path("/v1/quote"))
1583 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1584 "error": "queue full",
1585 "code": "QUEUE_FULL"
1586 })))
1587 .up_to_n_times(1)
1588 .mount(&server)
1589 .await;
1590
1591 let success_body = serde_json::json!({
1593 "orders": [{
1594 "order_id": "retry-order",
1595 "status": "success",
1596 "amount_in": "1000000",
1597 "amount_out": "990000",
1598 "gas_estimate": "50000",
1599 "amount_out_net_gas": "940000",
1600 "price_impact_bps": null,
1601 "block": {
1602 "number": 1234568,
1603 "hash": "0xabcdef01",
1604 "timestamp": 1700000012
1605 }
1606 }],
1607 "total_gas_estimate": "50000",
1608 "solve_time_ms": 10
1609 });
1610 Mock::given(method("POST"))
1611 .and(path("/v1/quote"))
1612 .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
1613 .up_to_n_times(1)
1614 .mount(&server)
1615 .await;
1616
1617 let retry = RetryConfig::new(3, Duration::from_millis(1), Duration::from_millis(10));
1618 let (client, _asserter) = make_test_client(server.uri(), retry, None);
1619
1620 let quote = client
1621 .quote(make_quote_params())
1622 .await
1623 .expect("should succeed after retry");
1624 assert_eq!(quote.order_id(), "retry-order");
1625 }
1626
1627 #[tokio::test]
1628 async fn quote_exhausts_retries_and_returns_last_error() {
1629 use wiremock::{
1630 matchers::{method, path},
1631 Mock, MockServer, ResponseTemplate,
1632 };
1633
1634 use crate::error::ErrorCode;
1635
1636 let server = MockServer::start().await;
1637
1638 Mock::given(method("POST"))
1639 .and(path("/v1/quote"))
1640 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1641 "error": "queue full",
1642 "code": "QUEUE_FULL"
1643 })))
1644 .mount(&server)
1645 .await;
1646
1647 let retry = RetryConfig::new(2, Duration::from_millis(1), Duration::from_millis(10));
1648 let (client, _asserter) = make_test_client(server.uri(), retry, None);
1649
1650 let err = client
1651 .quote(make_quote_params())
1652 .await
1653 .unwrap_err();
1654 assert!(
1655 matches!(err, FyndError::Api { code: ErrorCode::ServiceUnavailable, .. }),
1656 "expected ServiceUnavailable after retry exhaustion, got {err:?}"
1657 );
1658 }
1659
1660 #[tokio::test]
1661 async fn quote_returns_error_on_malformed_response() {
1662 use wiremock::{
1663 matchers::{method, path},
1664 Mock, MockServer, ResponseTemplate,
1665 };
1666
1667 let server = MockServer::start().await;
1668
1669 Mock::given(method("POST"))
1670 .and(path("/v1/quote"))
1671 .respond_with(
1672 ResponseTemplate::new(200).set_body_json(serde_json::json!({"garbage": true})),
1673 )
1674 .mount(&server)
1675 .await;
1676
1677 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1678
1679 let err = client
1680 .quote(make_quote_params())
1681 .await
1682 .unwrap_err();
1683 assert!(
1685 matches!(err, FyndError::Http(_)),
1686 "expected Http deserialization error, got {err:?}"
1687 );
1688 }
1689
1690 #[tokio::test]
1695 async fn health_returns_status_on_success() {
1696 use wiremock::{
1697 matchers::{method, path},
1698 Mock, MockServer, ResponseTemplate,
1699 };
1700
1701 let server = MockServer::start().await;
1702
1703 Mock::given(method("GET"))
1704 .and(path("/v1/health"))
1705 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1706 "healthy": true,
1707 "last_update_ms": 100,
1708 "num_solver_pools": 5
1709 })))
1710 .expect(1)
1711 .mount(&server)
1712 .await;
1713
1714 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1715
1716 let status = client
1717 .health()
1718 .await
1719 .expect("health should succeed");
1720 assert!(status.healthy());
1721 assert_eq!(status.last_update_ms(), 100);
1722 assert_eq!(status.num_solver_pools(), 5);
1723 }
1724
1725 #[tokio::test]
1726 async fn health_returns_error_on_server_failure() {
1727 use wiremock::{
1728 matchers::{method, path},
1729 Mock, MockServer, ResponseTemplate,
1730 };
1731
1732 let server = MockServer::start().await;
1733
1734 Mock::given(method("GET"))
1735 .and(path("/v1/health"))
1736 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1737 "error": "service unavailable",
1738 "code": "NOT_READY"
1739 })))
1740 .expect(1)
1741 .mount(&server)
1742 .await;
1743
1744 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1745
1746 let err = client.health().await.unwrap_err();
1747 assert!(matches!(err, FyndError::Api { .. }), "expected Api error, got {err:?}");
1748 }
1749
1750 #[tokio::test]
1755 async fn swap_payload_uses_hints_when_all_provided() {
1756 let sender = Address::with_last_byte(0xab);
1757 let (client, _asserter) =
1758 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1759
1760 let quote = make_order_quote();
1761 let hints = SigningHints {
1762 sender: Some(sender),
1763 nonce: Some(5),
1764 max_fee_per_gas: Some(1_000_000_000),
1765 max_priority_fee_per_gas: Some(1_000_000),
1766 gas_limit: Some(100_000),
1767 simulate: false,
1768 };
1769
1770 let payload = client
1771 .swap_payload(quote, &hints)
1772 .await
1773 .expect("swap_payload should succeed");
1774
1775 let SwapPayload::Fynd(fynd) = payload else {
1776 panic!("expected Fynd payload");
1777 };
1778 let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1779 panic!("expected EIP-1559 transaction");
1780 };
1781 assert_eq!(tx.nonce, 5);
1782 assert_eq!(tx.max_fee_per_gas, 1_000_000_000);
1783 assert_eq!(tx.max_priority_fee_per_gas, 1_000_000);
1784 assert_eq!(tx.gas_limit, 100_000);
1785 }
1786
1787 #[tokio::test]
1788 async fn swap_payload_fetches_nonce_and_fees_when_hints_absent() {
1789 let sender = Address::with_last_byte(0xde);
1790 let (client, asserter) =
1791 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1792
1793 asserter.push_success(&7u64);
1795 let fee_history = serde_json::json!({
1799 "oldestBlock": "0x1",
1800 "baseFeePerGas": ["0x3b9aca00", "0x3b9aca00"],
1801 "gasUsedRatio": [0.5],
1802 "reward": [["0xf4240", "0x1e8480"]]
1803 });
1804 asserter.push_success(&fee_history);
1805 asserter.push_success(&150_000u64);
1807
1808 let quote = make_order_quote();
1809 let hints = SigningHints::default();
1810
1811 let payload = client
1812 .swap_payload(quote, &hints)
1813 .await
1814 .expect("swap_payload should succeed");
1815
1816 let SwapPayload::Fynd(fynd) = payload else {
1817 panic!("expected Fynd payload");
1818 };
1819 let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1820 panic!("expected EIP-1559 transaction");
1821 };
1822 assert_eq!(tx.nonce, 7, "nonce should come from mock");
1823 assert_eq!(tx.gas_limit, 150_000, "gas limit should come from eth_estimateGas");
1824 }
1825
1826 #[tokio::test]
1827 async fn swap_payload_returns_config_error_when_no_sender() {
1828 let (client, _asserter) =
1830 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1831
1832 let quote = make_order_quote();
1833 let hints = SigningHints::default(); let err = client
1836 .swap_payload(quote, &hints)
1837 .await
1838 .unwrap_err();
1839
1840 assert!(matches!(err, FyndError::Config(_)), "expected Config error, got {err:?}");
1841 }
1842
1843 #[tokio::test]
1844 async fn swap_payload_with_simulate_true_calls_eth_call_successfully() {
1845 let sender = Address::with_last_byte(0xab);
1846 let (client, asserter) =
1847 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1848
1849 let quote = make_order_quote();
1850 let hints = SigningHints {
1851 sender: Some(sender),
1852 nonce: Some(1),
1853 max_fee_per_gas: Some(1_000_000_000),
1854 max_priority_fee_per_gas: Some(1_000_000),
1855 gas_limit: Some(100_000),
1856 simulate: true,
1857 };
1858
1859 asserter.push_success(&alloy::primitives::Bytes::new());
1861
1862 let payload = client
1863 .swap_payload(quote, &hints)
1864 .await
1865 .expect("swap_payload with simulate=true should succeed");
1866
1867 assert!(matches!(payload, SwapPayload::Fynd(_)));
1868 }
1869
1870 #[tokio::test]
1871 async fn swap_payload_with_simulate_true_returns_simulation_failed_on_revert() {
1872 let sender = Address::with_last_byte(0xab);
1873 let (client, asserter) =
1874 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1875
1876 let quote = make_order_quote();
1877 let hints = SigningHints {
1878 sender: Some(sender),
1879 nonce: Some(1),
1880 max_fee_per_gas: Some(1_000_000_000),
1881 max_priority_fee_per_gas: Some(1_000_000),
1882 gas_limit: Some(100_000),
1883 simulate: true,
1884 };
1885
1886 asserter.push_failure_msg("execution reverted");
1888
1889 let err = client
1890 .swap_payload(quote, &hints)
1891 .await
1892 .unwrap_err();
1893
1894 assert!(
1895 matches!(err, FyndError::SimulationFailed(_)),
1896 "expected SimulationFailed, got {err:?}"
1897 );
1898 }
1899
1900 fn make_signed_swap() -> SignedSwap {
1909 use alloy::{
1910 eips::eip2930::AccessList,
1911 primitives::{Bytes as AlloyBytes, Signature, TxKind, U256},
1912 };
1913
1914 use crate::signing::FyndPayload;
1915
1916 let quote = make_order_quote();
1917 let tx = TxEip1559 {
1918 chain_id: 1,
1919 nonce: 1,
1920 max_fee_per_gas: 1_000_000_000,
1921 max_priority_fee_per_gas: 1_000_000,
1922 gas_limit: 100_000,
1923 to: TxKind::Call(Address::ZERO),
1924 value: U256::ZERO,
1925 input: AlloyBytes::new(),
1926 access_list: AccessList::default(),
1927 };
1928 let payload =
1929 SwapPayload::Fynd(Box::new(FyndPayload::new(quote, TypedTransaction::Eip1559(tx))));
1930 SignedSwap::assemble(payload, Signature::test_signature())
1931 }
1932
1933 #[tokio::test]
1934 async fn execute_dry_run_returns_settled_order_without_broadcast() {
1935 let sender = Address::with_last_byte(0xab);
1936 let (client, asserter) =
1937 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1938
1939 let mut amount_bytes = vec![0u8; 32];
1941 amount_bytes[24..32].copy_from_slice(&990_000u64.to_be_bytes());
1942 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1943 asserter.push_success(&50_000u64); let order = make_signed_swap();
1946 let opts =
1947 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1948 let receipt = client
1949 .execute_swap(order, &opts)
1950 .await
1951 .expect("execute should succeed");
1952 let settled = receipt
1953 .await
1954 .expect("should resolve immediately");
1955
1956 assert_eq!(settled.settled_amount(), Some(&num_bigint::BigUint::from(990_000u64)),);
1957 let expected_gas_cost =
1958 num_bigint::BigUint::from(50_000u64) * num_bigint::BigUint::from(1_000_000_000u64);
1959 assert_eq!(settled.gas_cost(), &expected_gas_cost);
1960 }
1961
1962 #[tokio::test]
1963 async fn execute_dry_run_with_storage_overrides_succeeds() {
1964 let sender = Address::with_last_byte(0xab);
1965 let (client, asserter) =
1966 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1967
1968 let mut overrides = StorageOverrides::default();
1969 overrides.insert(
1970 bytes::Bytes::copy_from_slice(&[0u8; 20]),
1971 bytes::Bytes::copy_from_slice(&[0u8; 32]),
1972 bytes::Bytes::copy_from_slice(&[1u8; 32]),
1973 );
1974
1975 let mut amount_bytes = vec![0u8; 32];
1976 amount_bytes[24..32].copy_from_slice(&100u64.to_be_bytes());
1977 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1978 asserter.push_success(&21_000u64);
1979
1980 let order = make_signed_swap();
1981 let opts = ExecutionOptions {
1982 dry_run: true,
1983 storage_overrides: Some(overrides),
1984 fetch_revert_reason: false,
1985 };
1986 let receipt = client
1987 .execute_swap(order, &opts)
1988 .await
1989 .expect("execute with overrides should succeed");
1990 receipt.await.expect("should resolve");
1991 }
1992
1993 #[tokio::test]
1994 async fn execute_dry_run_returns_simulation_failed_on_call_error() {
1995 let sender = Address::with_last_byte(0xab);
1996 let (client, asserter) =
1997 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1998
1999 asserter.push_failure_msg("execution reverted");
2000
2001 let order = make_signed_swap();
2002 let opts =
2003 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
2004 let result = client.execute_swap(order, &opts).await;
2005 let err = match result {
2006 Err(e) => e,
2007 Ok(_) => panic!("expected SimulationFailed error"),
2008 };
2009
2010 assert!(
2011 matches!(err, FyndError::SimulationFailed(_)),
2012 "expected SimulationFailed, got {err:?}"
2013 );
2014 }
2015
2016 #[tokio::test]
2017 async fn execute_dry_run_with_empty_return_data_has_no_settled_amount() {
2018 let sender = Address::with_last_byte(0xab);
2019 let (client, asserter) =
2020 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
2021
2022 asserter.push_success(&alloy::primitives::Bytes::new());
2023 asserter.push_success(&21_000u64);
2024
2025 let order = make_signed_swap();
2026 let opts =
2027 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
2028 let receipt = client
2029 .execute_swap(order, &opts)
2030 .await
2031 .expect("execute should succeed");
2032 let settled = receipt.await.expect("should resolve");
2033
2034 assert!(
2035 settled.settled_amount().is_none(),
2036 "empty return data should yield None settled_amount"
2037 );
2038 }
2039
2040 #[tokio::test]
2041 async fn swap_payload_returns_protocol_error_when_no_transaction() {
2042 use crate::types::{BackendKind, BlockInfo, QuoteStatus};
2043
2044 let sender = Address::with_last_byte(0xab);
2045 let (client, _asserter) =
2046 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
2047
2048 let quote = crate::types::Quote::new(
2050 "no-tx".to_string(),
2051 QuoteStatus::Success,
2052 BackendKind::Fynd,
2053 None,
2054 num_bigint::BigUint::from(1_000u64),
2055 num_bigint::BigUint::from(990u64),
2056 num_bigint::BigUint::from(50_000u64),
2057 num_bigint::BigUint::from(940u64),
2058 None,
2059 BlockInfo::new(1, "0xabc".to_string(), 0),
2060 bytes::Bytes::copy_from_slice(&[0xbb; 20]),
2061 bytes::Bytes::copy_from_slice(&[0xcc; 20]),
2062 None,
2063 None,
2064 );
2065 let hints = SigningHints {
2066 sender: Some(sender),
2067 nonce: Some(1),
2068 max_fee_per_gas: Some(1_000_000_000),
2069 max_priority_fee_per_gas: Some(1_000_000),
2070 gas_limit: Some(100_000),
2071 simulate: false,
2072 };
2073
2074 let err = client
2075 .swap_payload(quote, &hints)
2076 .await
2077 .unwrap_err();
2078
2079 assert!(
2080 matches!(err, FyndError::Protocol(_)),
2081 "expected Protocol error when quote has no transaction, got {err:?}"
2082 );
2083 }
2084
2085 fn make_quote_params() -> QuoteParams {
2090 use crate::types::{Order, OrderSide, QuoteOptions};
2091
2092 let token_in = bytes::Bytes::copy_from_slice(&[0xaa; 20]);
2093 let token_out = bytes::Bytes::copy_from_slice(&[0xbb; 20]);
2094 let sender = bytes::Bytes::copy_from_slice(&[0xcc; 20]);
2095
2096 let order = Order::new(
2097 token_in,
2098 token_out,
2099 num_bigint::BigUint::from(1_000_000u64),
2100 OrderSide::Sell,
2101 sender,
2102 None,
2103 );
2104
2105 QuoteParams::new(order, QuoteOptions::default())
2106 }
2107
2108 fn make_info_body() -> serde_json::Value {
2113 serde_json::json!({
2114 "chain_id": 1,
2115 "router_address": "0x0101010101010101010101010101010101010101",
2116 "permit2_address": "0x0202020202020202020202020202020202020202"
2117 })
2118 }
2119
2120 #[tokio::test]
2121 async fn info_fetches_and_caches() {
2122 use wiremock::{
2123 matchers::{method, path},
2124 Mock, MockServer, ResponseTemplate,
2125 };
2126
2127 let server = MockServer::start().await;
2128
2129 Mock::given(method("GET"))
2130 .and(path("/v1/info"))
2131 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2132 .expect(1) .mount(&server)
2134 .await;
2135
2136 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
2137
2138 let info1 = client
2139 .info()
2140 .await
2141 .expect("first info call should succeed");
2142 let info2 = client
2143 .info()
2144 .await
2145 .expect("second info call should use cache");
2146
2147 assert_eq!(info1.chain_id(), 1);
2148 assert_eq!(info2.chain_id(), 1);
2149 assert_eq!(
2150 info1
2151 .router_address()
2152 .expect("mock info response includes a router address")
2153 .as_ref(),
2154 &[0x01u8; 20],
2155 );
2156 assert_eq!(info1.permit2_address().as_ref(), &[0x02u8; 20]);
2157 }
2159
2160 #[tokio::test]
2165 async fn approval_builds_correct_calldata() {
2166 use wiremock::{
2167 matchers::{method, path},
2168 Mock, MockServer, ResponseTemplate,
2169 };
2170
2171 let server = MockServer::start().await;
2172
2173 Mock::given(method("GET"))
2174 .and(path("/v1/info"))
2175 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2176 .expect(1)
2177 .mount(&server)
2178 .await;
2179
2180 let sender = Address::with_last_byte(0xab);
2181 let (client, asserter) =
2182 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2183
2184 let hints = SigningHints {
2186 sender: Some(sender),
2187 nonce: Some(3),
2188 max_fee_per_gas: Some(2_000_000_000),
2189 max_priority_fee_per_gas: Some(1_000_000),
2190 gas_limit: None, simulate: false,
2192 };
2193 asserter.push_success(&65_000u64);
2195
2196 let params = ApprovalParams::new(
2197 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2198 num_bigint::BigUint::from(1_000_000u64),
2199 AllowanceCheck::Skip,
2200 );
2201
2202 let payload = client
2203 .approval(¶ms, &hints)
2204 .await
2205 .expect("approval should succeed")
2206 .expect("should build payload when AllowanceCheck::Skip");
2207
2208 let selector = &payload.tx().input[0..4];
2210 assert_eq!(selector, &[0x09, 0x5e, 0xa7, 0xb3]);
2211 assert_eq!(payload.tx().gas_limit, 65_000, "gas limit should come from eth_estimateGas");
2212 assert_eq!(payload.tx().nonce, 3);
2213 }
2214
2215 #[tokio::test]
2216 async fn approval_with_insufficient_allowance_returns_some() {
2217 use wiremock::{
2218 matchers::{method, path},
2219 Mock, MockServer, ResponseTemplate,
2220 };
2221
2222 let server = MockServer::start().await;
2223
2224 Mock::given(method("GET"))
2225 .and(path("/v1/info"))
2226 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2227 .expect(1)
2228 .mount(&server)
2229 .await;
2230
2231 let sender = Address::with_last_byte(0xab);
2232 let (client, asserter) =
2233 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2234
2235 let hints = SigningHints {
2236 sender: Some(sender),
2237 nonce: Some(0),
2238 max_fee_per_gas: Some(1_000_000_000),
2239 max_priority_fee_per_gas: Some(1_000_000),
2240 gas_limit: None,
2241 simulate: false,
2242 };
2243
2244 let zero_allowance = alloy::primitives::Bytes::copy_from_slice(&[0u8; 32]);
2246 asserter.push_success(&zero_allowance);
2247 asserter.push_success(&65_000u64);
2249
2250 let params = ApprovalParams::new(
2251 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2252 num_bigint::BigUint::from(500_000u64),
2253 AllowanceCheck::AtLeast(num_bigint::BigUint::from(500_000u64)),
2254 );
2255
2256 let result = client
2257 .approval(¶ms, &hints)
2258 .await
2259 .expect("approval with allowance check should succeed");
2260
2261 assert!(result.is_some(), "zero allowance should return a payload");
2262 }
2263
2264 #[tokio::test]
2265 async fn approval_with_sufficient_allowance_returns_none() {
2266 use wiremock::{
2267 matchers::{method, path},
2268 Mock, MockServer, ResponseTemplate,
2269 };
2270
2271 let server = MockServer::start().await;
2272
2273 Mock::given(method("GET"))
2274 .and(path("/v1/info"))
2275 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2276 .expect(1)
2277 .mount(&server)
2278 .await;
2279
2280 let sender = Address::with_last_byte(0xab);
2281 let (client, asserter) =
2282 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2283
2284 let hints = SigningHints {
2285 sender: Some(sender),
2286 nonce: Some(0),
2287 max_fee_per_gas: Some(1_000_000_000),
2288 max_priority_fee_per_gas: Some(1_000_000),
2289 gas_limit: None,
2290 simulate: false,
2291 };
2292
2293 let mut allowance_bytes = [0u8; 32];
2295 allowance_bytes[24..32].copy_from_slice(&1_000_000u64.to_be_bytes());
2297 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&allowance_bytes));
2298
2299 let params = ApprovalParams::new(
2301 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2302 num_bigint::BigUint::from(500_000u64),
2303 AllowanceCheck::AtLeast(num_bigint::BigUint::from(500_000u64)),
2304 );
2305
2306 let result = client
2307 .approval(¶ms, &hints)
2308 .await
2309 .expect("approval with sufficient allowance check should succeed");
2310
2311 assert!(result.is_none(), "sufficient allowance should return None");
2312 }
2313
2314 fn make_signed_approval() -> crate::signing::SignedApproval {
2319 use alloy::primitives::{Signature, TxKind, U256};
2320
2321 use crate::signing::ApprovalPayload;
2322
2323 let tx = TxEip1559 {
2324 chain_id: 1,
2325 nonce: 0,
2326 max_fee_per_gas: 1_000_000_000,
2327 max_priority_fee_per_gas: 1_000_000,
2328 gas_limit: 65_000,
2329 to: TxKind::Call(Address::ZERO),
2330 value: U256::ZERO,
2331 input: AlloyBytes::from(vec![0x09, 0x5e, 0xa7, 0xb3]),
2332 access_list: AccessList::default(),
2333 };
2334 let payload = ApprovalPayload {
2335 tx,
2336 token: bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2337 spender: bytes::Bytes::copy_from_slice(&[0x01; 20]),
2338 amount: num_bigint::BigUint::from(1_000_000u64),
2339 };
2340 SignedApproval::assemble(payload, Signature::test_signature())
2341 }
2342
2343 #[tokio::test]
2344 async fn execute_approval_broadcasts_and_polls() {
2345 let sender = Address::with_last_byte(0xab);
2346 let (client, asserter) =
2347 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
2348
2349 let tx_hash = alloy::primitives::B256::repeat_byte(0xef);
2351 asserter.push_success(&tx_hash);
2352
2353 asserter.push_success::<Option<()>>(&None);
2355 let receipt = alloy::rpc::types::TransactionReceipt {
2356 inner: alloy::consensus::ReceiptEnvelope::Eip1559(alloy::consensus::ReceiptWithBloom {
2357 receipt: alloy::consensus::Receipt::<alloy::primitives::Log> {
2358 status: alloy::consensus::Eip658Value::Eip658(true),
2359 cumulative_gas_used: 50_000,
2360 logs: vec![],
2361 },
2362 logs_bloom: alloy::primitives::Bloom::default(),
2363 }),
2364 transaction_hash: tx_hash,
2365 transaction_index: None,
2366 block_hash: None,
2367 block_number: None,
2368 gas_used: 45_000,
2369 effective_gas_price: 1_500_000_000,
2370 blob_gas_used: None,
2371 blob_gas_price: None,
2372 from: Address::ZERO,
2373 to: None,
2374 contract_address: None,
2375 };
2376 asserter.push_success(&receipt);
2377
2378 let approval = make_signed_approval();
2379 let tx_receipt = client
2380 .execute_approval(approval)
2381 .await
2382 .expect("execute_approval should succeed");
2383
2384 let mined = tx_receipt
2385 .await
2386 .expect("receipt should resolve");
2387
2388 assert_eq!(mined.tx_hash(), tx_hash);
2389 let expected_cost =
2390 num_bigint::BigUint::from(45_000u64) * num_bigint::BigUint::from(1_500_000_000u64);
2391 assert_eq!(mined.gas_cost(), &expected_cost);
2392 }
2393
2394 fn hosted(api_key: Option<&str>, chain: Option<&str>) -> HostedConfig {
2399 HostedConfig { api_key: api_key.map(str::to_owned), chain: chain.map(str::to_owned) }
2400 }
2401
2402 #[test]
2403 fn chain_id_for_slug_resolves_supported_chains() {
2404 assert_eq!(chain_id_for_slug("ethereum").unwrap(), 1);
2405 assert_eq!(chain_id_for_slug("base").unwrap(), 8453);
2406 assert_eq!(chain_id_for_slug("arbitrum").unwrap(), 42161);
2407 assert_eq!(chain_id_for_slug("bsc").unwrap(), 56);
2408 assert_eq!(chain_id_for_slug("polygon").unwrap(), 137);
2409 assert_eq!(chain_id_for_slug("unichain").unwrap(), 130);
2410 assert_eq!(chain_id_for_slug("robinhood").unwrap(), 4663);
2411 }
2412
2413 #[test]
2414 fn chain_id_for_slug_rejects_unknown_chain() {
2415 let err = chain_id_for_slug("sepolia").unwrap_err();
2416 let FyndError::Config(msg) = err else {
2417 panic!("expected Config error, got {err:?}");
2418 };
2419 assert!(msg.contains("sepolia"), "error should name the bad slug: {msg}");
2420 assert!(msg.contains("ethereum"), "error should list supported slugs: {msg}");
2421 }
2422
2423 #[rstest::rstest]
2424 #[case::base("base", 8453)]
2425 #[case::robinhood("robinhood", 4663)]
2426 fn build_quote_only_derives_chain_id_from_chain(
2427 #[case] chain: &str,
2428 #[case] expected_chain_id: u64,
2429 ) {
2430 let client = FyndClientBuilder::new("http://localhost:8080")
2431 .with_chain(chain)
2432 .build_quote_only()
2433 .expect("build_quote_only should succeed");
2434 assert_eq!(client.chain_id, expected_chain_id);
2435 }
2436
2437 #[test]
2438 fn build_quote_only_defaults_to_mainnet_without_chain() {
2439 let client = FyndClientBuilder::new("http://localhost:8080")
2440 .build_quote_only()
2441 .expect("build_quote_only should succeed");
2442 assert_eq!(client.chain_id, 1);
2443 }
2444
2445 #[test]
2446 fn build_quote_only_rejects_unknown_chain() {
2447 let result = FyndClientBuilder::new("http://localhost:8080")
2448 .with_chain("not-a-chain")
2449 .build_quote_only();
2450 let Err(err) = result else {
2451 panic!("expected an unknown chain slug to be rejected");
2452 };
2453 assert!(matches!(err, FyndError::Config(_)), "expected Config error, got {err:?}");
2454 }
2455
2456 #[test]
2457 fn endpoint_inserts_chain_segment_only_when_configured() {
2458 let legacy = FyndClientBuilder::new("http://localhost:8080")
2459 .build_quote_only()
2460 .expect("build");
2461 assert_eq!(legacy.endpoint("quote"), "http://localhost:8080/v1/quote");
2462
2463 let scoped = FyndClientBuilder::new("http://localhost:8080")
2464 .with_chain("base")
2465 .build_quote_only()
2466 .expect("build");
2467 assert_eq!(scoped.endpoint("quote"), "http://localhost:8080/v1/base/quote");
2468 }
2469
2470 #[tokio::test]
2471 async fn quote_sends_api_key_to_chain_scoped_path() {
2472 use wiremock::{
2473 matchers::{header, method, path},
2474 Mock, MockServer, ResponseTemplate,
2475 };
2476
2477 let server = MockServer::start().await;
2478 let body = serde_json::json!({
2479 "orders": [{
2480 "order_id": "hosted-1",
2481 "status": "success",
2482 "amount_in": "1000000",
2483 "amount_out": "990000",
2484 "gas_estimate": "50000",
2485 "amount_out_net_gas": "940000",
2486 "price_impact_bps": null,
2487 "block": { "number": 1, "hash": "0xabc", "timestamp": 1 }
2488 }],
2489 "total_gas_estimate": "50000",
2490 "solve_time_ms": 1
2491 });
2492
2493 Mock::given(method("POST"))
2494 .and(path("/v1/base/quote"))
2495 .and(header("authorization", "secret-key"))
2496 .respond_with(ResponseTemplate::new(200).set_body_json(body))
2497 .expect(1)
2498 .mount(&server)
2499 .await;
2500
2501 let (client, _asserter) = make_hosted_test_client(
2502 server.uri(),
2503 RetryConfig::default(),
2504 None,
2505 hosted(Some("secret-key"), Some("base")),
2506 );
2507
2508 let quote = client
2509 .quote(make_quote_params())
2510 .await
2511 .expect("quote should succeed");
2512 assert_eq!(quote.order_id(), "hosted-1");
2513 }
2514
2515 #[tokio::test]
2516 async fn quote_omits_authorization_header_without_api_key() {
2517 use wiremock::{
2518 matchers::{method, path},
2519 Mock, MockServer, ResponseTemplate,
2520 };
2521
2522 let server = MockServer::start().await;
2523 let body = serde_json::json!({
2524 "orders": [{
2525 "order_id": "legacy-1",
2526 "status": "success",
2527 "amount_in": "1000000",
2528 "amount_out": "990000",
2529 "gas_estimate": "50000",
2530 "amount_out_net_gas": "940000",
2531 "price_impact_bps": null,
2532 "block": { "number": 1, "hash": "0xabc", "timestamp": 1 }
2533 }],
2534 "total_gas_estimate": "50000",
2535 "solve_time_ms": 1
2536 });
2537
2538 Mock::given(method("POST"))
2540 .and(path("/v1/quote"))
2541 .respond_with(ResponseTemplate::new(200).set_body_json(body))
2542 .expect(1)
2543 .mount(&server)
2544 .await;
2545
2546 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
2547 client
2548 .quote(make_quote_params())
2549 .await
2550 .expect("quote should succeed");
2551
2552 let requests = server
2553 .received_requests()
2554 .await
2555 .expect("recorded requests");
2556 let request = requests.first().expect("one request");
2557 assert!(
2558 !request
2559 .headers
2560 .contains_key("authorization"),
2561 "no API key configured, so no Authorization header should be sent"
2562 );
2563 }
2564
2565 #[tokio::test]
2566 async fn health_uses_chain_scoped_path_with_api_key() {
2567 use wiremock::{
2568 matchers::{header, method, path},
2569 Mock, MockServer, ResponseTemplate,
2570 };
2571
2572 let server = MockServer::start().await;
2573
2574 Mock::given(method("GET"))
2575 .and(path("/v1/arbitrum/health"))
2576 .and(header("authorization", "secret-key"))
2577 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2578 "healthy": true,
2579 "last_update_ms": 100,
2580 "num_solver_pools": 5
2581 })))
2582 .expect(1)
2583 .mount(&server)
2584 .await;
2585
2586 let (client, _asserter) = make_hosted_test_client(
2587 server.uri(),
2588 RetryConfig::default(),
2589 None,
2590 hosted(Some("secret-key"), Some("arbitrum")),
2591 );
2592
2593 let status = client
2594 .health()
2595 .await
2596 .expect("health should succeed");
2597 assert!(status.healthy());
2598 }
2599
2600 #[tokio::test]
2601 async fn info_uses_chain_scoped_path_with_api_key() {
2602 use wiremock::{
2603 matchers::{header, method, path},
2604 Mock, MockServer, ResponseTemplate,
2605 };
2606
2607 let server = MockServer::start().await;
2608
2609 Mock::given(method("GET"))
2610 .and(path("/v1/unichain/info"))
2611 .and(header("authorization", "secret-key"))
2612 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2613 .expect(1)
2614 .mount(&server)
2615 .await;
2616
2617 let (client, _asserter) = make_hosted_test_client(
2618 server.uri(),
2619 RetryConfig::default(),
2620 None,
2621 hosted(Some("secret-key"), Some("unichain")),
2622 );
2623
2624 let info = client
2625 .info()
2626 .await
2627 .expect("info should succeed");
2628 assert_eq!(info.chain_id(), 1, "chain_id comes from the server payload");
2629 }
2630
2631 #[tokio::test]
2632 async fn api_key_without_chain_keeps_legacy_paths() {
2633 use wiremock::{
2634 matchers::{header, method, path},
2635 Mock, MockServer, ResponseTemplate,
2636 };
2637
2638 let server = MockServer::start().await;
2639
2640 Mock::given(method("GET"))
2641 .and(path("/v1/health"))
2642 .and(header("authorization", "secret-key"))
2643 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2644 "healthy": true,
2645 "last_update_ms": 1,
2646 "num_solver_pools": 1
2647 })))
2648 .expect(1)
2649 .mount(&server)
2650 .await;
2651
2652 let (client, _asserter) = make_hosted_test_client(
2653 server.uri(),
2654 RetryConfig::default(),
2655 None,
2656 hosted(Some("secret-key"), None),
2657 );
2658
2659 client
2660 .health()
2661 .await
2662 .expect("health should succeed");
2663 }
2664}