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); 6] = [
367 ("ethereum", 1),
368 ("base", 8453),
369 ("arbitrum", 42161),
370 ("bsc", 56),
371 ("polygon", 137),
372 ("unichain", 130),
373];
374
375fn 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#[derive(Clone, Default)]
399pub struct HostedConfig {
400 pub api_key: Option<String>,
403 pub chain: Option<String>,
405}
406
407pub 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 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 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 pub fn with_chain(mut self, chain: impl Into<String>) -> Self {
466 self.hosted.chain = Some(chain.into());
467 self
468 }
469
470 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 pub fn with_timeout(mut self, timeout: Duration) -> Self {
481 self.timeout = timeout;
482 self
483 }
484
485 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
487 self.retry = retry;
488 self
489 }
490
491 pub fn with_submit_url(mut self, url: impl Into<String>) -> Self {
495 self.submit_url = Some(url.into());
496 self
497 }
498
499 pub fn with_sender(mut self, sender: Address) -> Self {
501 self.sender = Some(sender);
502 self
503 }
504
505 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 let chain_id = match &self.hosted.chain {
531 Some(chain) => chain_id_for_slug(chain)?,
532 None => 1,
533 };
534
535 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 pub async fn build(self) -> Result<FyndClient, FyndError> {
565 let expected_chain_id = self
567 .hosted
568 .chain
569 .as_deref()
570 .map(chain_id_for_slug)
571 .transpose()?;
572
573 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 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 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 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 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
640pub 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 #[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 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 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 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 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 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 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 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 let sender = hints
875 .sender()
876 .or(self.default_sender)
877 .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
878
879 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 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 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 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 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 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 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 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 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(¶ms.token)?;
1140 let amount_u256 = mapping::biguint_to_u256(¶ms.amount);
1141
1142 if let AllowanceCheck::AtLeast(min) = ¶ms.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 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 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 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 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 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
1358fn decode_revert_bytes(data: &[u8]) -> String {
1363 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 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 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 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 #[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 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 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 assert!(
1684 matches!(err, FyndError::Http(_)),
1685 "expected Http deserialization error, got {err:?}"
1686 );
1687 }
1688
1689 #[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 #[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 asserter.push_success(&7u64);
1794 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 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 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(); 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 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 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 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 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); 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 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 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 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) .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 }
2158
2159 #[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 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, simulate: false,
2191 };
2192 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(¶ms, &hints)
2203 .await
2204 .expect("approval should succeed")
2205 .expect("should build payload when AllowanceCheck::Skip");
2206
2207 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 let zero_allowance = alloy::primitives::Bytes::copy_from_slice(&[0u8; 32]);
2245 asserter.push_success(&zero_allowance);
2246 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(¶ms, &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 let mut allowance_bytes = [0u8; 32];
2294 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 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(¶ms, &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 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 let tx_hash = alloy::primitives::B256::repeat_byte(0xef);
2350 asserter.push_success(&tx_hash);
2351
2352 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 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 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}