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
361pub struct FyndClientBuilder {
373 base_url: String,
374 timeout: Duration,
375 retry: RetryConfig,
376 rpc_url: Option<String>,
377 submit_url: Option<String>,
378 sender: Option<Address>,
379}
380
381impl FyndClientBuilder {
382 pub fn new(base_url: impl Into<String>) -> Self {
391 Self {
392 base_url: base_url.into(),
393 timeout: Duration::from_secs(30),
394 retry: RetryConfig::default(),
395 rpc_url: None,
396 submit_url: None,
397 sender: None,
398 }
399 }
400
401 pub fn with_rpc_url(mut self, rpc_url: impl Into<String>) -> Self {
406 self.rpc_url = Some(rpc_url.into());
407 self
408 }
409
410 pub fn with_timeout(mut self, timeout: Duration) -> Self {
412 self.timeout = timeout;
413 self
414 }
415
416 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
418 self.retry = retry;
419 self
420 }
421
422 pub fn with_submit_url(mut self, url: impl Into<String>) -> Self {
426 self.submit_url = Some(url.into());
427 self
428 }
429
430 pub fn with_sender(mut self, sender: Address) -> Self {
432 self.sender = Some(sender);
433 self
434 }
435
436 pub fn build_quote_only(self) -> Result<FyndClient, FyndError> {
444 let parsed_base = self
445 .base_url
446 .parse::<reqwest::Url>()
447 .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
448 let scheme = parsed_base.scheme();
449 if scheme != "http" && scheme != "https" {
450 return Err(FyndError::Config(format!(
451 "base URL must use http or https scheme, got '{scheme}'"
452 )));
453 }
454
455 let provider = ProviderBuilder::default().connect_http(parsed_base.clone());
458 let submit_provider = ProviderBuilder::default().connect_http(parsed_base);
459
460 let http = HttpClient::builder()
461 .timeout(self.timeout)
462 .build()
463 .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
464
465 Ok(FyndClient {
466 http,
467 base_url: self.base_url,
468 retry: self.retry,
469 chain_id: 1,
470 default_sender: self.sender,
471 provider,
472 submit_provider,
473 info_cache: tokio::sync::OnceCell::new(),
474 })
475 }
476
477 pub async fn build(self) -> Result<FyndClient, FyndError> {
483 let parsed_base = self
485 .base_url
486 .parse::<reqwest::Url>()
487 .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
488 let scheme = parsed_base.scheme();
489 if scheme != "http" && scheme != "https" {
490 return Err(FyndError::Config(format!(
491 "base URL must use http or https scheme, got '{scheme}'"
492 )));
493 }
494
495 let rpc_url_str = self
497 .rpc_url
498 .ok_or_else(|| FyndError::Config("rpc_url is required: call with_rpc_url()".into()))?;
499 let rpc_url = rpc_url_str
500 .parse::<reqwest::Url>()
501 .map_err(|e| FyndError::Config(format!("invalid RPC URL: {e}")))?;
502 let provider = ProviderBuilder::default().connect_http(rpc_url);
503
504 let submit_url_str = self
505 .submit_url
506 .as_deref()
507 .unwrap_or(&rpc_url_str);
508 let submit_url = submit_url_str
509 .parse::<reqwest::Url>()
510 .map_err(|e| FyndError::Config(format!("invalid submit URL: {e}")))?;
511 let submit_provider = ProviderBuilder::default().connect_http(submit_url);
512
513 let chain_id = provider
515 .get_chain_id()
516 .await
517 .map_err(|e| FyndError::Config(format!("failed to fetch chain_id from RPC: {e}")))?;
518
519 let http = HttpClient::builder()
521 .timeout(self.timeout)
522 .build()
523 .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
524
525 Ok(FyndClient {
526 http,
527 base_url: self.base_url,
528 retry: self.retry,
529 chain_id,
530 default_sender: self.sender,
531 provider,
532 submit_provider,
533 info_cache: tokio::sync::OnceCell::new(),
534 })
535 }
536}
537
538pub struct FyndClient<P = RootProvider<Ethereum>>
549where
550 P: Provider<Ethereum> + Clone + Send + Sync + 'static,
551{
552 http: HttpClient,
553 base_url: String,
554 retry: RetryConfig,
555 chain_id: u64,
556 default_sender: Option<Address>,
557 provider: P,
558 submit_provider: P,
559 info_cache: tokio::sync::OnceCell<InstanceInfo>,
560}
561
562impl<P> FyndClient<P>
563where
564 P: Provider<Ethereum> + Clone + Send + Sync + 'static,
565{
566 #[doc(hidden)]
570 #[allow(clippy::too_many_arguments)]
571 pub fn new_with_providers(
572 http: HttpClient,
573 base_url: String,
574 retry: RetryConfig,
575 chain_id: u64,
576 default_sender: Option<Address>,
577 provider: P,
578 submit_provider: P,
579 ) -> Self {
580 Self {
581 http,
582 base_url,
583 retry,
584 chain_id,
585 default_sender,
586 provider,
587 submit_provider,
588 info_cache: tokio::sync::OnceCell::new(),
589 }
590 }
591
592 pub async fn quote(&self, params: QuoteParams) -> Result<Quote, FyndError> {
599 let token_out = params.order.token_out().clone();
600 let receiver = params
601 .order
602 .receiver()
603 .unwrap_or_else(|| params.order.sender())
604 .clone();
605 let dto_request = mapping::quote_params_to_dto(params)?;
606
607 let mut delay = self.retry.initial_backoff;
608 for attempt in 0..self.retry.max_attempts {
609 match self
610 .request_quote(&dto_request, token_out.clone(), receiver.clone())
611 .await
612 {
613 Ok(quote) => return Ok(quote),
614 Err(e) if e.is_retryable() && attempt + 1 < self.retry.max_attempts => {
615 tracing::debug!(attempt, "quote request failed, retrying");
616 tokio::time::sleep(delay).await;
617 delay = (delay * 2).min(self.retry.max_backoff);
618 }
619 Err(e) => return Err(e),
620 }
621 }
622 Err(FyndError::Protocol("retry loop exhausted without result".into()))
623 }
624
625 async fn request_quote(
626 &self,
627 dto_request: &fynd_rpc_types::QuoteRequest,
628 token_out: Bytes,
629 receiver: Bytes,
630 ) -> Result<Quote, FyndError> {
631 let url = format!("{}/v1/quote", self.base_url);
632 let response = self
633 .http
634 .post(&url)
635 .json(dto_request)
636 .send()
637 .await?;
638 if !response.status().is_success() {
639 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
640 return Err(mapping::dto_error_to_fynd(dto_err));
641 }
642 let dto_quote: fynd_rpc_types::Quote = response.json().await?;
643 mapping::map_quote_response(dto_quote, vec![(token_out, receiver)])?
644 .into_iter()
645 .next()
646 .ok_or_else(|| FyndError::Protocol("server returned empty quote list".into()))
647 }
648
649 pub async fn batch_quote(&self, params: BatchQuoteParams) -> Result<Vec<Quote>, FyndError> {
657 let (dto_request, order_meta) = mapping::batch_quote_params_to_dto(params)?;
658
659 let mut delay = self.retry.initial_backoff;
660 for attempt in 0..self.retry.max_attempts {
661 match self
662 .request_batch_quote(&dto_request, order_meta.clone())
663 .await
664 {
665 Ok(quotes) => return Ok(quotes),
666 Err(e) if e.is_retryable() && attempt + 1 < self.retry.max_attempts => {
667 tracing::debug!(attempt, "batch_quote request failed, retrying");
668 tokio::time::sleep(delay).await;
669 delay = (delay * 2).min(self.retry.max_backoff);
670 }
671 Err(e) => return Err(e),
672 }
673 }
674 Err(FyndError::Protocol("retry loop exhausted without result".into()))
675 }
676
677 async fn request_batch_quote(
678 &self,
679 dto_request: &fynd_rpc_types::QuoteRequest,
680 order_meta: Vec<(Bytes, Bytes)>,
681 ) -> Result<Vec<Quote>, FyndError> {
682 let url = format!("{}/v1/quote", self.base_url);
683 let response = self
684 .http
685 .post(&url)
686 .json(dto_request)
687 .send()
688 .await?;
689 if !response.status().is_success() {
690 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
691 return Err(mapping::dto_error_to_fynd(dto_err));
692 }
693 let dto_quote: fynd_rpc_types::Quote = response.json().await?;
694 mapping::map_quote_response(dto_quote, order_meta)
695 }
696
697 pub async fn health(&self) -> Result<HealthStatus, FyndError> {
699 let url = format!("{}/v1/health", self.base_url);
700 let response = self.http.get(&url).send().await?;
701 let status = response.status();
702 let body = response.text().await?;
703 if let Ok(dh) = serde_json::from_str::<fynd_rpc_types::HealthStatus>(&body) {
706 return Ok(HealthStatus::from(dh));
707 }
708 if let Ok(dto_err) = serde_json::from_str::<fynd_rpc_types::ErrorResponse>(&body) {
709 return Err(mapping::dto_error_to_fynd(dto_err));
710 }
711 Err(FyndError::Protocol(format!("unexpected health response ({status}): {body}")))
712 }
713
714 pub async fn swap_payload(
726 &self,
727 quote: Quote,
728 hints: &SigningHints,
729 ) -> Result<SwapPayload, FyndError> {
730 match quote.backend() {
731 BackendKind::Fynd => {
732 self.fynd_swap_payload(quote, hints)
733 .await
734 }
735 BackendKind::Turbine => {
736 Err(FyndError::Protocol("Turbine signing not yet implemented".into()))
737 }
738 }
739 }
740
741 async fn fynd_swap_payload(
742 &self,
743 quote: Quote,
744 hints: &SigningHints,
745 ) -> Result<SwapPayload, FyndError> {
746 let sender = hints
748 .sender()
749 .or(self.default_sender)
750 .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
751
752 let nonce = match hints.nonce() {
754 Some(n) => n,
755 None => self
756 .provider
757 .get_transaction_count(sender)
758 .await
759 .map_err(FyndError::Provider)?,
760 };
761
762 let (max_fee_per_gas, max_priority_fee_per_gas) =
764 match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
765 (Some(mf), Some(mp)) => (mf, mp),
766 (mf, mp) => {
767 let est = self
768 .provider
769 .estimate_eip1559_fees()
770 .await
771 .map_err(FyndError::Provider)?;
772 (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
773 }
774 };
775
776 let tx_data = quote.transaction().ok_or_else(|| {
777 FyndError::Protocol(
778 "quote has no calldata; set encoding_options in QuoteOptions".into(),
779 )
780 })?;
781 let to_addr = mapping::bytes_to_alloy_address(tx_data.to())?;
782 let value = mapping::biguint_to_u256(tx_data.value());
783 let input = AlloyBytes::from(tx_data.data().to_vec());
784
785 let gas_limit = match hints.gas_limit() {
789 Some(g) => g,
790 None => {
791 let req = alloy::rpc::types::TransactionRequest::default()
792 .from(sender)
793 .to(to_addr)
794 .value(value)
795 .input(input.clone().into());
796 self.provider
797 .estimate_gas(req)
798 .await
799 .map_err(FyndError::Provider)?
800 }
801 };
802
803 let tx_eip1559 = TxEip1559 {
804 chain_id: self.chain_id,
805 nonce,
806 max_fee_per_gas,
807 max_priority_fee_per_gas,
808 gas_limit,
809 to: TxKind::Call(to_addr),
810 value,
811 input,
812 access_list: AccessList::default(),
813 };
814
815 if hints.simulate() {
817 let req = alloy::rpc::types::TransactionRequest::from_transaction_with_sender(
818 tx_eip1559.clone(),
819 sender,
820 );
821 self.provider
822 .call(req)
823 .await
824 .map_err(|e| {
825 FyndError::SimulationFailed(format!("transaction simulation failed: {e}"))
826 })?;
827 }
828
829 let tx = TypedTransaction::Eip1559(tx_eip1559);
830 Ok(SwapPayload::Fynd(Box::new(FyndPayload::new(quote, tx))))
831 }
832
833 pub async fn execute_swap(
844 &self,
845 order: SignedSwap,
846 options: &ExecutionOptions,
847 ) -> Result<ExecutionReceipt, FyndError> {
848 let (payload, signature) = order.into_parts();
849 let (quote, tx) = payload.into_fynd_parts()?;
850
851 let TypedTransaction::Eip1559(tx_eip1559) = tx else {
852 return Err(FyndError::Protocol(
853 "only EIP-1559 transactions are supported for execution".into(),
854 ));
855 };
856
857 if options.dry_run {
858 return self
859 .dry_run_execute(tx_eip1559, options)
860 .await;
861 }
862
863 let tx_hash = self
864 .send_raw(tx_eip1559.clone(), signature)
865 .await?;
866
867 let token_out_addr = mapping::bytes_to_alloy_address(quote.token_out())?;
868 let receiver_addr = mapping::bytes_to_alloy_address(quote.receiver())?;
869 let provider = self.submit_provider.clone();
870 let fetch_revert = options.fetch_revert_reason;
871 let fallback_to = match tx_eip1559.to {
875 TxKind::Call(addr) => addr,
876 TxKind::Create => Address::ZERO,
877 };
878 let fallback_req = TransactionRequest::default()
879 .to(fallback_to)
880 .value(tx_eip1559.value)
881 .input(tx_eip1559.input.clone().into());
882
883 Ok(ExecutionReceipt::Transaction(Box::pin(async move {
884 loop {
885 match provider
886 .get_transaction_receipt(tx_hash)
887 .await
888 .map_err(FyndError::Provider)?
889 {
890 Some(receipt) => {
891 if !receipt.status() {
892 let reason = if fetch_revert {
893 let trace: Result<serde_json::Value, _> = provider
895 .raw_request(
896 std::borrow::Cow::Borrowed("debug_traceTransaction"),
897 (tx_hash, serde_json::json!({})),
898 )
899 .await;
900 match trace {
901 Ok(t) => {
902 let hex_str = t
903 .get("returnValue")
904 .and_then(|v| v.as_str())
905 .unwrap_or("");
906 match alloy::primitives::hex::decode(
907 hex_str.trim_start_matches("0x"),
908 ) {
909 Ok(b) => decode_revert_bytes(&b),
910 Err(_) => format!(
911 "{tx_hash:#x} reverted (return value: {hex_str})"
912 ),
913 }
914 }
915 Err(_) => {
916 tracing::warn!(
917 tx = ?tx_hash,
918 "debug_traceTransaction unavailable; replaying via \
919 eth_call — block state may differ"
920 );
921 match provider.call(fallback_req).await {
922 Err(e) => e.to_string(),
923 Ok(_) => {
924 format!("{tx_hash:#x} reverted (no reason)")
925 }
926 }
927 }
928 }
929 } else {
930 format!("{tx_hash:#x}")
931 };
932 return Err(FyndError::TransactionReverted(reason));
933 }
934 let settled_amount =
935 compute_settled_amount(&receipt, &token_out_addr, &receiver_addr);
936 let gas_cost = BigUint::from(receipt.gas_used) *
937 BigUint::from(receipt.effective_gas_price);
938 return Ok(SettledOrder::new(Some(tx_hash), settled_amount, gas_cost));
939 }
940 None => tokio::time::sleep(Duration::from_secs(2)).await,
941 }
942 }
943 })))
944 }
945
946 pub async fn info(&self) -> Result<&InstanceInfo, FyndError> {
951 self.info_cache
952 .get_or_try_init(|| self.fetch_info())
953 .await
954 }
955
956 async fn fetch_info(&self) -> Result<InstanceInfo, FyndError> {
957 let url = format!("{}/v1/info", self.base_url);
958 let response = self.http.get(&url).send().await?;
959 if !response.status().is_success() {
960 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
961 return Err(mapping::dto_error_to_fynd(dto_err));
962 }
963 let dto_info: fynd_rpc_types::InstanceInfo = response.json().await?;
964 dto_info.try_into()
965 }
966
967 pub async fn approval(
981 &self,
982 params: &ApprovalParams,
983 hints: &SigningHints,
984 ) -> Result<Option<ApprovalPayload>, FyndError> {
985 use alloy::sol_types::SolCall;
986
987 let info = self.info().await?;
988 let spender_addr = match params.transfer_type {
989 UserTransferType::TransferFrom => {
990 mapping::bytes_to_alloy_address(info.router_address())?
991 }
992 UserTransferType::TransferFromPermit2 => {
993 mapping::bytes_to_alloy_address(info.permit2_address())?
994 }
995 UserTransferType::UseVaultsFunds => return Ok(None),
996 };
997
998 let sender = hints
999 .sender()
1000 .or(self.default_sender)
1001 .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
1002
1003 let token_addr = mapping::bytes_to_alloy_address(¶ms.token)?;
1004 let amount_u256 = mapping::biguint_to_u256(¶ms.amount);
1005
1006 if let AllowanceCheck::AtLeast(min) = ¶ms.allowance_check {
1008 let call_data =
1009 erc20::allowanceCall { owner: sender, spender: spender_addr }.abi_encode();
1010 let req = alloy::rpc::types::TransactionRequest {
1011 to: Some(alloy::primitives::TxKind::Call(token_addr)),
1012 input: alloy::rpc::types::TransactionInput::new(AlloyBytes::from(call_data)),
1013 ..Default::default()
1014 };
1015 let result = self
1016 .provider
1017 .call(req)
1018 .await
1019 .map_err(|e| FyndError::Protocol(format!("allowance call failed: {e}")))?;
1020 let current_allowance = if result.len() >= 32 {
1021 alloy::primitives::U256::from_be_slice(&result[0..32])
1022 } else {
1023 alloy::primitives::U256::ZERO
1024 };
1025 if current_allowance >= mapping::biguint_to_u256(min) {
1026 return Ok(None);
1027 }
1028 }
1029
1030 let nonce = match hints.nonce() {
1032 Some(n) => n,
1033 None => self
1034 .provider
1035 .get_transaction_count(sender)
1036 .await
1037 .map_err(FyndError::Provider)?,
1038 };
1039
1040 let (max_fee_per_gas, max_priority_fee_per_gas) =
1042 match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
1043 (Some(mf), Some(mp)) => (mf, mp),
1044 (mf, mp) => {
1045 let est = self
1046 .provider
1047 .estimate_eip1559_fees()
1048 .await
1049 .map_err(FyndError::Provider)?;
1050 (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
1051 }
1052 };
1053
1054 let calldata =
1055 erc20::approveCall { spender: spender_addr, amount: amount_u256 }.abi_encode();
1056
1057 let gas_limit = match hints.gas_limit() {
1059 Some(g) => g,
1060 None => {
1061 let req = alloy::rpc::types::TransactionRequest::default()
1062 .from(sender)
1063 .to(token_addr)
1064 .input(AlloyBytes::from(calldata.clone()).into());
1065 self.provider
1066 .estimate_gas(req)
1067 .await
1068 .map_err(FyndError::Provider)?
1069 }
1070 };
1071
1072 let tx = TxEip1559 {
1073 chain_id: self.chain_id,
1074 nonce,
1075 max_fee_per_gas,
1076 max_priority_fee_per_gas,
1077 gas_limit,
1078 to: alloy::primitives::TxKind::Call(token_addr),
1079 value: alloy::primitives::U256::ZERO,
1080 input: AlloyBytes::from(calldata),
1081 access_list: alloy::eips::eip2930::AccessList::default(),
1082 };
1083
1084 let spender = bytes::Bytes::copy_from_slice(spender_addr.as_slice());
1085 Ok(Some(ApprovalPayload {
1086 tx,
1087 token: params.token.clone(),
1088 spender,
1089 amount: params.amount.clone(),
1090 }))
1091 }
1092
1093 pub async fn execute_approval(&self, approval: SignedApproval) -> Result<TxReceipt, FyndError> {
1099 let (payload, signature) = approval.into_parts();
1100 let fallback_req = TransactionRequest::default()
1101 .to(mapping::bytes_to_alloy_address(&payload.token)?)
1102 .input(payload.tx.input.clone().into());
1103 let tx_hash = self
1104 .send_raw(payload.tx, signature)
1105 .await?;
1106 let provider = self.submit_provider.clone();
1107
1108 Ok(TxReceipt::Pending(Box::pin(async move {
1109 loop {
1110 match provider
1111 .get_transaction_receipt(tx_hash)
1112 .await
1113 .map_err(FyndError::Provider)?
1114 {
1115 Some(receipt) => {
1116 if !receipt.status() {
1117 let trace: Result<serde_json::Value, _> = provider
1118 .raw_request(
1119 std::borrow::Cow::Borrowed("debug_traceTransaction"),
1120 (tx_hash, serde_json::json!({})),
1121 )
1122 .await;
1123 let reason = match trace {
1124 Ok(t) => {
1125 let hex_str = t
1126 .get("returnValue")
1127 .and_then(|v| v.as_str())
1128 .unwrap_or("");
1129 match alloy::primitives::hex::decode(
1130 hex_str.trim_start_matches("0x"),
1131 ) {
1132 Ok(b) => decode_revert_bytes(&b),
1133 Err(_) => format!(
1134 "{tx_hash:#x} reverted (return value: {hex_str})"
1135 ),
1136 }
1137 }
1138 Err(_) => {
1139 tracing::warn!(
1140 tx = ?tx_hash,
1141 "debug_traceTransaction unavailable; replaying via \
1142 eth_call — block state may differ"
1143 );
1144 match provider.call(fallback_req).await {
1145 Err(e) => e.to_string(),
1146 Ok(_) => format!("{tx_hash:#x} reverted (no reason)"),
1147 }
1148 }
1149 };
1150 return Err(FyndError::TransactionReverted(reason));
1151 }
1152 let gas_cost = BigUint::from(receipt.gas_used) *
1153 BigUint::from(receipt.effective_gas_price);
1154 return Ok(MinedTx::new(tx_hash, gas_cost));
1155 }
1156 None => tokio::time::sleep(Duration::from_secs(2)).await,
1157 }
1158 }
1159 })))
1160 }
1161
1162 async fn send_raw(
1164 &self,
1165 tx: TxEip1559,
1166 signature: alloy::primitives::Signature,
1167 ) -> Result<B256, FyndError> {
1168 use alloy::eips::eip2718::Encodable2718;
1169 let envelope = TypedTransaction::Eip1559(tx).into_envelope(signature);
1170 let raw = envelope.encoded_2718();
1171 let pending = self
1172 .submit_provider
1173 .send_raw_transaction(&raw)
1174 .await
1175 .map_err(FyndError::Provider)?;
1176 Ok(*pending.tx_hash())
1177 }
1178
1179 async fn dry_run_execute(
1180 &self,
1181 tx_eip1559: TxEip1559,
1182 options: &ExecutionOptions,
1183 ) -> Result<ExecutionReceipt, FyndError> {
1184 let mut req: TransactionRequest = tx_eip1559.clone().into();
1185 if let Some(sender) = self.default_sender {
1186 req.from = Some(sender);
1187 }
1188 let overrides = options
1189 .storage_overrides
1190 .as_ref()
1191 .map(storage_overrides_to_alloy)
1192 .transpose()?;
1193
1194 let return_data = self
1195 .provider
1196 .call(req.clone())
1197 .overrides_opt(overrides.clone())
1198 .await
1199 .map_err(|e| FyndError::SimulationFailed(format!("dry run simulation failed: {e}")))?;
1200
1201 let gas_used = self
1202 .provider
1203 .estimate_gas(req)
1204 .overrides_opt(overrides)
1205 .await
1206 .map_err(|e| {
1207 FyndError::SimulationFailed(format!("dry run gas estimation failed: {e}"))
1208 })?;
1209
1210 let settled_amount = if return_data.len() >= 32 {
1211 Some(BigUint::from_bytes_be(&return_data[0..32]))
1212 } else {
1213 None
1214 };
1215 let gas_cost = BigUint::from(gas_used) * BigUint::from(tx_eip1559.max_fee_per_gas);
1216 let settled = SettledOrder::new(None, settled_amount, gas_cost);
1217
1218 Ok(ExecutionReceipt::Transaction(Box::pin(async move { Ok(settled) })))
1219 }
1220}
1221
1222fn decode_revert_bytes(data: &[u8]) -> String {
1227 const SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0];
1229 if data.len() >= 68 && data[..4] == SELECTOR {
1230 let str_len = u64::from_be_bytes(
1231 data[60..68]
1232 .try_into()
1233 .unwrap_or([0u8; 8]),
1234 ) as usize;
1235 if data.len() >= 68 + str_len {
1236 if let Ok(s) = std::str::from_utf8(&data[68..68 + str_len]) {
1237 return s.to_owned();
1238 }
1239 }
1240 }
1241 if data.is_empty() {
1242 "empty revert data".to_owned()
1243 } else {
1244 format!("0x{}", alloy::primitives::hex::encode(data))
1245 }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250 use std::time::Duration;
1251
1252 use super::*;
1253
1254 #[test]
1255 fn retry_config_default_values() {
1256 let config = RetryConfig::default();
1257 assert_eq!(config.max_attempts(), 3);
1258 assert_eq!(config.initial_backoff(), Duration::from_millis(100));
1259 assert_eq!(config.max_backoff(), Duration::from_secs(2));
1260 }
1261
1262 #[test]
1263 fn signing_hints_default_all_none_and_no_simulate() {
1264 let hints = SigningHints::default();
1265 assert!(hints.sender().is_none());
1266 assert!(hints.nonce().is_none());
1267 assert!(!hints.simulate());
1268 }
1269
1270 fn make_test_client(
1279 base_url: String,
1280 retry: RetryConfig,
1281 default_sender: Option<Address>,
1282 ) -> (FyndClient<alloy::providers::RootProvider<Ethereum>>, alloy::providers::mock::Asserter)
1283 {
1284 use alloy::providers::{mock::Asserter, ProviderBuilder};
1285
1286 let asserter = Asserter::new();
1287 let provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1288 let submit_provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1289
1290 let http = HttpClient::builder()
1291 .timeout(Duration::from_secs(5))
1292 .build()
1293 .expect("reqwest client");
1294
1295 let client = FyndClient::new_with_providers(
1296 http,
1297 base_url,
1298 retry,
1299 1,
1300 default_sender,
1301 provider,
1302 submit_provider,
1303 );
1304
1305 (client, asserter)
1306 }
1307
1308 fn make_order_quote() -> crate::types::Quote {
1310 use num_bigint::BigUint;
1311
1312 use crate::types::{BackendKind, BlockInfo, QuoteStatus, Transaction};
1313
1314 let tx = Transaction::new(
1315 bytes::Bytes::copy_from_slice(&[0x01; 20]),
1316 BigUint::ZERO,
1317 vec![0x12, 0x34],
1318 );
1319
1320 crate::types::Quote::new(
1321 "test-order-id".to_string(),
1322 QuoteStatus::Success,
1323 BackendKind::Fynd,
1324 None,
1325 BigUint::from(1_000_000u64),
1326 BigUint::from(990_000u64),
1327 BigUint::from(50_000u64),
1328 BigUint::from(940_000u64),
1329 Some(10),
1330 BlockInfo::new(1_234_567, "0xabcdef".to_string(), 1_700_000_000),
1331 bytes::Bytes::copy_from_slice(&[0xbb; 20]),
1332 bytes::Bytes::copy_from_slice(&[0xcc; 20]),
1333 Some(tx),
1334 None,
1335 )
1336 }
1337
1338 #[tokio::test]
1343 async fn quote_returns_parsed_quote_on_success() {
1344 use wiremock::{
1345 matchers::{method, path},
1346 Mock, MockServer, ResponseTemplate,
1347 };
1348
1349 let server = MockServer::start().await;
1350 let body = serde_json::json!({
1351 "orders": [{
1352 "order_id": "abc-123",
1353 "status": "success",
1354 "amount_in": "1000000",
1355 "amount_out": "990000",
1356 "gas_estimate": "50000",
1357 "amount_out_net_gas": "940000",
1358 "price_impact_bps": 10,
1359 "block": {
1360 "number": 1234567,
1361 "hash": "0xabcdef",
1362 "timestamp": 1700000000
1363 }
1364 }],
1365 "total_gas_estimate": "50000",
1366 "solve_time_ms": 42
1367 });
1368
1369 Mock::given(method("POST"))
1370 .and(path("/v1/quote"))
1371 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1372 .expect(1)
1373 .mount(&server)
1374 .await;
1375
1376 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1377
1378 let params = make_quote_params();
1379 let quote = client
1380 .quote(params)
1381 .await
1382 .expect("quote should succeed");
1383
1384 assert_eq!(quote.order_id(), "abc-123");
1385 assert_eq!(quote.amount_out(), &num_bigint::BigUint::from(990_000u64));
1386 }
1387
1388 #[tokio::test]
1389 async fn quote_returns_api_error_on_non_retryable_server_error() {
1390 use wiremock::{
1391 matchers::{method, path},
1392 Mock, MockServer, ResponseTemplate,
1393 };
1394
1395 use crate::error::ErrorCode;
1396
1397 let server = MockServer::start().await;
1398
1399 Mock::given(method("POST"))
1400 .and(path("/v1/quote"))
1401 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1402 "error": "bad input",
1403 "code": "BAD_REQUEST"
1404 })))
1405 .expect(1)
1406 .mount(&server)
1407 .await;
1408
1409 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1410
1411 let err = client
1412 .quote(make_quote_params())
1413 .await
1414 .unwrap_err();
1415 assert!(
1416 matches!(err, FyndError::Api { code: ErrorCode::BadRequest, .. }),
1417 "expected BadRequest, got {err:?}"
1418 );
1419 }
1420
1421 #[tokio::test]
1422 async fn quote_retries_on_retryable_error_then_succeeds() {
1423 use wiremock::{
1424 matchers::{method, path},
1425 Mock, MockServer, ResponseTemplate,
1426 };
1427
1428 let server = MockServer::start().await;
1429
1430 Mock::given(method("POST"))
1432 .and(path("/v1/quote"))
1433 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1434 "error": "queue full",
1435 "code": "QUEUE_FULL"
1436 })))
1437 .up_to_n_times(1)
1438 .mount(&server)
1439 .await;
1440
1441 let success_body = serde_json::json!({
1443 "orders": [{
1444 "order_id": "retry-order",
1445 "status": "success",
1446 "amount_in": "1000000",
1447 "amount_out": "990000",
1448 "gas_estimate": "50000",
1449 "amount_out_net_gas": "940000",
1450 "price_impact_bps": null,
1451 "block": {
1452 "number": 1234568,
1453 "hash": "0xabcdef01",
1454 "timestamp": 1700000012
1455 }
1456 }],
1457 "total_gas_estimate": "50000",
1458 "solve_time_ms": 10
1459 });
1460 Mock::given(method("POST"))
1461 .and(path("/v1/quote"))
1462 .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
1463 .up_to_n_times(1)
1464 .mount(&server)
1465 .await;
1466
1467 let retry = RetryConfig::new(3, Duration::from_millis(1), Duration::from_millis(10));
1468 let (client, _asserter) = make_test_client(server.uri(), retry, None);
1469
1470 let quote = client
1471 .quote(make_quote_params())
1472 .await
1473 .expect("should succeed after retry");
1474 assert_eq!(quote.order_id(), "retry-order");
1475 }
1476
1477 #[tokio::test]
1478 async fn quote_exhausts_retries_and_returns_last_error() {
1479 use wiremock::{
1480 matchers::{method, path},
1481 Mock, MockServer, ResponseTemplate,
1482 };
1483
1484 use crate::error::ErrorCode;
1485
1486 let server = MockServer::start().await;
1487
1488 Mock::given(method("POST"))
1489 .and(path("/v1/quote"))
1490 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1491 "error": "queue full",
1492 "code": "QUEUE_FULL"
1493 })))
1494 .mount(&server)
1495 .await;
1496
1497 let retry = RetryConfig::new(2, Duration::from_millis(1), Duration::from_millis(10));
1498 let (client, _asserter) = make_test_client(server.uri(), retry, None);
1499
1500 let err = client
1501 .quote(make_quote_params())
1502 .await
1503 .unwrap_err();
1504 assert!(
1505 matches!(err, FyndError::Api { code: ErrorCode::ServiceUnavailable, .. }),
1506 "expected ServiceUnavailable after retry exhaustion, got {err:?}"
1507 );
1508 }
1509
1510 #[tokio::test]
1511 async fn quote_returns_error_on_malformed_response() {
1512 use wiremock::{
1513 matchers::{method, path},
1514 Mock, MockServer, ResponseTemplate,
1515 };
1516
1517 let server = MockServer::start().await;
1518
1519 Mock::given(method("POST"))
1520 .and(path("/v1/quote"))
1521 .respond_with(
1522 ResponseTemplate::new(200).set_body_json(serde_json::json!({"garbage": true})),
1523 )
1524 .mount(&server)
1525 .await;
1526
1527 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1528
1529 let err = client
1530 .quote(make_quote_params())
1531 .await
1532 .unwrap_err();
1533 assert!(
1535 matches!(err, FyndError::Http(_)),
1536 "expected Http deserialization error, got {err:?}"
1537 );
1538 }
1539
1540 #[tokio::test]
1545 async fn health_returns_status_on_success() {
1546 use wiremock::{
1547 matchers::{method, path},
1548 Mock, MockServer, ResponseTemplate,
1549 };
1550
1551 let server = MockServer::start().await;
1552
1553 Mock::given(method("GET"))
1554 .and(path("/v1/health"))
1555 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1556 "healthy": true,
1557 "last_update_ms": 100,
1558 "num_solver_pools": 5
1559 })))
1560 .expect(1)
1561 .mount(&server)
1562 .await;
1563
1564 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1565
1566 let status = client
1567 .health()
1568 .await
1569 .expect("health should succeed");
1570 assert!(status.healthy());
1571 assert_eq!(status.last_update_ms(), 100);
1572 assert_eq!(status.num_solver_pools(), 5);
1573 }
1574
1575 #[tokio::test]
1576 async fn health_returns_error_on_server_failure() {
1577 use wiremock::{
1578 matchers::{method, path},
1579 Mock, MockServer, ResponseTemplate,
1580 };
1581
1582 let server = MockServer::start().await;
1583
1584 Mock::given(method("GET"))
1585 .and(path("/v1/health"))
1586 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1587 "error": "service unavailable",
1588 "code": "NOT_READY"
1589 })))
1590 .expect(1)
1591 .mount(&server)
1592 .await;
1593
1594 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1595
1596 let err = client.health().await.unwrap_err();
1597 assert!(matches!(err, FyndError::Api { .. }), "expected Api error, got {err:?}");
1598 }
1599
1600 #[tokio::test]
1605 async fn swap_payload_uses_hints_when_all_provided() {
1606 let sender = Address::with_last_byte(0xab);
1607 let (client, _asserter) =
1608 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1609
1610 let quote = make_order_quote();
1611 let hints = SigningHints {
1612 sender: Some(sender),
1613 nonce: Some(5),
1614 max_fee_per_gas: Some(1_000_000_000),
1615 max_priority_fee_per_gas: Some(1_000_000),
1616 gas_limit: Some(100_000),
1617 simulate: false,
1618 };
1619
1620 let payload = client
1621 .swap_payload(quote, &hints)
1622 .await
1623 .expect("swap_payload should succeed");
1624
1625 let SwapPayload::Fynd(fynd) = payload else {
1626 panic!("expected Fynd payload");
1627 };
1628 let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1629 panic!("expected EIP-1559 transaction");
1630 };
1631 assert_eq!(tx.nonce, 5);
1632 assert_eq!(tx.max_fee_per_gas, 1_000_000_000);
1633 assert_eq!(tx.max_priority_fee_per_gas, 1_000_000);
1634 assert_eq!(tx.gas_limit, 100_000);
1635 }
1636
1637 #[tokio::test]
1638 async fn swap_payload_fetches_nonce_and_fees_when_hints_absent() {
1639 let sender = Address::with_last_byte(0xde);
1640 let (client, asserter) =
1641 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1642
1643 asserter.push_success(&7u64);
1645 let fee_history = serde_json::json!({
1649 "oldestBlock": "0x1",
1650 "baseFeePerGas": ["0x3b9aca00", "0x3b9aca00"],
1651 "gasUsedRatio": [0.5],
1652 "reward": [["0xf4240", "0x1e8480"]]
1653 });
1654 asserter.push_success(&fee_history);
1655 asserter.push_success(&150_000u64);
1657
1658 let quote = make_order_quote();
1659 let hints = SigningHints::default();
1660
1661 let payload = client
1662 .swap_payload(quote, &hints)
1663 .await
1664 .expect("swap_payload should succeed");
1665
1666 let SwapPayload::Fynd(fynd) = payload else {
1667 panic!("expected Fynd payload");
1668 };
1669 let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1670 panic!("expected EIP-1559 transaction");
1671 };
1672 assert_eq!(tx.nonce, 7, "nonce should come from mock");
1673 assert_eq!(tx.gas_limit, 150_000, "gas limit should come from eth_estimateGas");
1674 }
1675
1676 #[tokio::test]
1677 async fn swap_payload_returns_config_error_when_no_sender() {
1678 let (client, _asserter) =
1680 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1681
1682 let quote = make_order_quote();
1683 let hints = SigningHints::default(); let err = client
1686 .swap_payload(quote, &hints)
1687 .await
1688 .unwrap_err();
1689
1690 assert!(matches!(err, FyndError::Config(_)), "expected Config error, got {err:?}");
1691 }
1692
1693 #[tokio::test]
1694 async fn swap_payload_with_simulate_true_calls_eth_call_successfully() {
1695 let sender = Address::with_last_byte(0xab);
1696 let (client, asserter) =
1697 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1698
1699 let quote = make_order_quote();
1700 let hints = SigningHints {
1701 sender: Some(sender),
1702 nonce: Some(1),
1703 max_fee_per_gas: Some(1_000_000_000),
1704 max_priority_fee_per_gas: Some(1_000_000),
1705 gas_limit: Some(100_000),
1706 simulate: true,
1707 };
1708
1709 asserter.push_success(&alloy::primitives::Bytes::new());
1711
1712 let payload = client
1713 .swap_payload(quote, &hints)
1714 .await
1715 .expect("swap_payload with simulate=true should succeed");
1716
1717 assert!(matches!(payload, SwapPayload::Fynd(_)));
1718 }
1719
1720 #[tokio::test]
1721 async fn swap_payload_with_simulate_true_returns_simulation_failed_on_revert() {
1722 let sender = Address::with_last_byte(0xab);
1723 let (client, asserter) =
1724 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1725
1726 let quote = make_order_quote();
1727 let hints = SigningHints {
1728 sender: Some(sender),
1729 nonce: Some(1),
1730 max_fee_per_gas: Some(1_000_000_000),
1731 max_priority_fee_per_gas: Some(1_000_000),
1732 gas_limit: Some(100_000),
1733 simulate: true,
1734 };
1735
1736 asserter.push_failure_msg("execution reverted");
1738
1739 let err = client
1740 .swap_payload(quote, &hints)
1741 .await
1742 .unwrap_err();
1743
1744 assert!(
1745 matches!(err, FyndError::SimulationFailed(_)),
1746 "expected SimulationFailed, got {err:?}"
1747 );
1748 }
1749
1750 fn make_signed_swap() -> SignedSwap {
1759 use alloy::{
1760 eips::eip2930::AccessList,
1761 primitives::{Bytes as AlloyBytes, Signature, TxKind, U256},
1762 };
1763
1764 use crate::signing::FyndPayload;
1765
1766 let quote = make_order_quote();
1767 let tx = TxEip1559 {
1768 chain_id: 1,
1769 nonce: 1,
1770 max_fee_per_gas: 1_000_000_000,
1771 max_priority_fee_per_gas: 1_000_000,
1772 gas_limit: 100_000,
1773 to: TxKind::Call(Address::ZERO),
1774 value: U256::ZERO,
1775 input: AlloyBytes::new(),
1776 access_list: AccessList::default(),
1777 };
1778 let payload =
1779 SwapPayload::Fynd(Box::new(FyndPayload::new(quote, TypedTransaction::Eip1559(tx))));
1780 SignedSwap::assemble(payload, Signature::test_signature())
1781 }
1782
1783 #[tokio::test]
1784 async fn execute_dry_run_returns_settled_order_without_broadcast() {
1785 let sender = Address::with_last_byte(0xab);
1786 let (client, asserter) =
1787 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1788
1789 let mut amount_bytes = vec![0u8; 32];
1791 amount_bytes[24..32].copy_from_slice(&990_000u64.to_be_bytes());
1792 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1793 asserter.push_success(&50_000u64); let order = make_signed_swap();
1796 let opts =
1797 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1798 let receipt = client
1799 .execute_swap(order, &opts)
1800 .await
1801 .expect("execute should succeed");
1802 let settled = receipt
1803 .await
1804 .expect("should resolve immediately");
1805
1806 assert_eq!(settled.settled_amount(), Some(&num_bigint::BigUint::from(990_000u64)),);
1807 let expected_gas_cost =
1808 num_bigint::BigUint::from(50_000u64) * num_bigint::BigUint::from(1_000_000_000u64);
1809 assert_eq!(settled.gas_cost(), &expected_gas_cost);
1810 }
1811
1812 #[tokio::test]
1813 async fn execute_dry_run_with_storage_overrides_succeeds() {
1814 let sender = Address::with_last_byte(0xab);
1815 let (client, asserter) =
1816 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1817
1818 let mut overrides = StorageOverrides::default();
1819 overrides.insert(
1820 bytes::Bytes::copy_from_slice(&[0u8; 20]),
1821 bytes::Bytes::copy_from_slice(&[0u8; 32]),
1822 bytes::Bytes::copy_from_slice(&[1u8; 32]),
1823 );
1824
1825 let mut amount_bytes = vec![0u8; 32];
1826 amount_bytes[24..32].copy_from_slice(&100u64.to_be_bytes());
1827 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1828 asserter.push_success(&21_000u64);
1829
1830 let order = make_signed_swap();
1831 let opts = ExecutionOptions {
1832 dry_run: true,
1833 storage_overrides: Some(overrides),
1834 fetch_revert_reason: false,
1835 };
1836 let receipt = client
1837 .execute_swap(order, &opts)
1838 .await
1839 .expect("execute with overrides should succeed");
1840 receipt.await.expect("should resolve");
1841 }
1842
1843 #[tokio::test]
1844 async fn execute_dry_run_returns_simulation_failed_on_call_error() {
1845 let sender = Address::with_last_byte(0xab);
1846 let (client, asserter) =
1847 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1848
1849 asserter.push_failure_msg("execution reverted");
1850
1851 let order = make_signed_swap();
1852 let opts =
1853 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1854 let result = client.execute_swap(order, &opts).await;
1855 let err = match result {
1856 Err(e) => e,
1857 Ok(_) => panic!("expected SimulationFailed error"),
1858 };
1859
1860 assert!(
1861 matches!(err, FyndError::SimulationFailed(_)),
1862 "expected SimulationFailed, got {err:?}"
1863 );
1864 }
1865
1866 #[tokio::test]
1867 async fn execute_dry_run_with_empty_return_data_has_no_settled_amount() {
1868 let sender = Address::with_last_byte(0xab);
1869 let (client, asserter) =
1870 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1871
1872 asserter.push_success(&alloy::primitives::Bytes::new());
1873 asserter.push_success(&21_000u64);
1874
1875 let order = make_signed_swap();
1876 let opts =
1877 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1878 let receipt = client
1879 .execute_swap(order, &opts)
1880 .await
1881 .expect("execute should succeed");
1882 let settled = receipt.await.expect("should resolve");
1883
1884 assert!(
1885 settled.settled_amount().is_none(),
1886 "empty return data should yield None settled_amount"
1887 );
1888 }
1889
1890 #[tokio::test]
1891 async fn swap_payload_returns_protocol_error_when_no_transaction() {
1892 use crate::types::{BackendKind, BlockInfo, QuoteStatus};
1893
1894 let sender = Address::with_last_byte(0xab);
1895 let (client, _asserter) =
1896 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1897
1898 let quote = crate::types::Quote::new(
1900 "no-tx".to_string(),
1901 QuoteStatus::Success,
1902 BackendKind::Fynd,
1903 None,
1904 num_bigint::BigUint::from(1_000u64),
1905 num_bigint::BigUint::from(990u64),
1906 num_bigint::BigUint::from(50_000u64),
1907 num_bigint::BigUint::from(940u64),
1908 None,
1909 BlockInfo::new(1, "0xabc".to_string(), 0),
1910 bytes::Bytes::copy_from_slice(&[0xbb; 20]),
1911 bytes::Bytes::copy_from_slice(&[0xcc; 20]),
1912 None,
1913 None,
1914 );
1915 let hints = SigningHints {
1916 sender: Some(sender),
1917 nonce: Some(1),
1918 max_fee_per_gas: Some(1_000_000_000),
1919 max_priority_fee_per_gas: Some(1_000_000),
1920 gas_limit: Some(100_000),
1921 simulate: false,
1922 };
1923
1924 let err = client
1925 .swap_payload(quote, &hints)
1926 .await
1927 .unwrap_err();
1928
1929 assert!(
1930 matches!(err, FyndError::Protocol(_)),
1931 "expected Protocol error when quote has no transaction, got {err:?}"
1932 );
1933 }
1934
1935 fn make_quote_params() -> QuoteParams {
1940 use crate::types::{Order, OrderSide, QuoteOptions};
1941
1942 let token_in = bytes::Bytes::copy_from_slice(&[0xaa; 20]);
1943 let token_out = bytes::Bytes::copy_from_slice(&[0xbb; 20]);
1944 let sender = bytes::Bytes::copy_from_slice(&[0xcc; 20]);
1945
1946 let order = Order::new(
1947 token_in,
1948 token_out,
1949 num_bigint::BigUint::from(1_000_000u64),
1950 OrderSide::Sell,
1951 sender,
1952 None,
1953 );
1954
1955 QuoteParams::new(order, QuoteOptions::default())
1956 }
1957
1958 fn make_info_body() -> serde_json::Value {
1963 serde_json::json!({
1964 "chain_id": 1,
1965 "router_address": "0x0101010101010101010101010101010101010101",
1966 "permit2_address": "0x0202020202020202020202020202020202020202"
1967 })
1968 }
1969
1970 #[tokio::test]
1971 async fn info_fetches_and_caches() {
1972 use wiremock::{
1973 matchers::{method, path},
1974 Mock, MockServer, ResponseTemplate,
1975 };
1976
1977 let server = MockServer::start().await;
1978
1979 Mock::given(method("GET"))
1980 .and(path("/v1/info"))
1981 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
1982 .expect(1) .mount(&server)
1984 .await;
1985
1986 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1987
1988 let info1 = client
1989 .info()
1990 .await
1991 .expect("first info call should succeed");
1992 let info2 = client
1993 .info()
1994 .await
1995 .expect("second info call should use cache");
1996
1997 assert_eq!(info1.chain_id(), 1);
1998 assert_eq!(info2.chain_id(), 1);
1999 assert_eq!(info1.router_address().as_ref(), &[0x01u8; 20]);
2000 assert_eq!(info1.permit2_address().as_ref(), &[0x02u8; 20]);
2001 }
2003
2004 #[tokio::test]
2009 async fn approval_builds_correct_calldata() {
2010 use wiremock::{
2011 matchers::{method, path},
2012 Mock, MockServer, ResponseTemplate,
2013 };
2014
2015 let server = MockServer::start().await;
2016
2017 Mock::given(method("GET"))
2018 .and(path("/v1/info"))
2019 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2020 .expect(1)
2021 .mount(&server)
2022 .await;
2023
2024 let sender = Address::with_last_byte(0xab);
2025 let (client, asserter) =
2026 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2027
2028 let hints = SigningHints {
2030 sender: Some(sender),
2031 nonce: Some(3),
2032 max_fee_per_gas: Some(2_000_000_000),
2033 max_priority_fee_per_gas: Some(1_000_000),
2034 gas_limit: None, simulate: false,
2036 };
2037 asserter.push_success(&65_000u64);
2039
2040 let params = ApprovalParams::new(
2041 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2042 num_bigint::BigUint::from(1_000_000u64),
2043 AllowanceCheck::Skip,
2044 );
2045
2046 let payload = client
2047 .approval(¶ms, &hints)
2048 .await
2049 .expect("approval should succeed")
2050 .expect("should build payload when AllowanceCheck::Skip");
2051
2052 let selector = &payload.tx().input[0..4];
2054 assert_eq!(selector, &[0x09, 0x5e, 0xa7, 0xb3]);
2055 assert_eq!(payload.tx().gas_limit, 65_000, "gas limit should come from eth_estimateGas");
2056 assert_eq!(payload.tx().nonce, 3);
2057 }
2058
2059 #[tokio::test]
2060 async fn approval_with_insufficient_allowance_returns_some() {
2061 use wiremock::{
2062 matchers::{method, path},
2063 Mock, MockServer, ResponseTemplate,
2064 };
2065
2066 let server = MockServer::start().await;
2067
2068 Mock::given(method("GET"))
2069 .and(path("/v1/info"))
2070 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2071 .expect(1)
2072 .mount(&server)
2073 .await;
2074
2075 let sender = Address::with_last_byte(0xab);
2076 let (client, asserter) =
2077 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2078
2079 let hints = SigningHints {
2080 sender: Some(sender),
2081 nonce: Some(0),
2082 max_fee_per_gas: Some(1_000_000_000),
2083 max_priority_fee_per_gas: Some(1_000_000),
2084 gas_limit: None,
2085 simulate: false,
2086 };
2087
2088 let zero_allowance = alloy::primitives::Bytes::copy_from_slice(&[0u8; 32]);
2090 asserter.push_success(&zero_allowance);
2091 asserter.push_success(&65_000u64);
2093
2094 let params = ApprovalParams::new(
2095 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2096 num_bigint::BigUint::from(500_000u64),
2097 AllowanceCheck::AtLeast(num_bigint::BigUint::from(500_000u64)),
2098 );
2099
2100 let result = client
2101 .approval(¶ms, &hints)
2102 .await
2103 .expect("approval with allowance check should succeed");
2104
2105 assert!(result.is_some(), "zero allowance should return a payload");
2106 }
2107
2108 #[tokio::test]
2109 async fn approval_with_sufficient_allowance_returns_none() {
2110 use wiremock::{
2111 matchers::{method, path},
2112 Mock, MockServer, ResponseTemplate,
2113 };
2114
2115 let server = MockServer::start().await;
2116
2117 Mock::given(method("GET"))
2118 .and(path("/v1/info"))
2119 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2120 .expect(1)
2121 .mount(&server)
2122 .await;
2123
2124 let sender = Address::with_last_byte(0xab);
2125 let (client, asserter) =
2126 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2127
2128 let hints = SigningHints {
2129 sender: Some(sender),
2130 nonce: Some(0),
2131 max_fee_per_gas: Some(1_000_000_000),
2132 max_priority_fee_per_gas: Some(1_000_000),
2133 gas_limit: None,
2134 simulate: false,
2135 };
2136
2137 let mut allowance_bytes = [0u8; 32];
2139 allowance_bytes[24..32].copy_from_slice(&1_000_000u64.to_be_bytes());
2141 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&allowance_bytes));
2142
2143 let params = ApprovalParams::new(
2145 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2146 num_bigint::BigUint::from(500_000u64),
2147 AllowanceCheck::AtLeast(num_bigint::BigUint::from(500_000u64)),
2148 );
2149
2150 let result = client
2151 .approval(¶ms, &hints)
2152 .await
2153 .expect("approval with sufficient allowance check should succeed");
2154
2155 assert!(result.is_none(), "sufficient allowance should return None");
2156 }
2157
2158 fn make_signed_approval() -> crate::signing::SignedApproval {
2163 use alloy::primitives::{Signature, TxKind, U256};
2164
2165 use crate::signing::ApprovalPayload;
2166
2167 let tx = TxEip1559 {
2168 chain_id: 1,
2169 nonce: 0,
2170 max_fee_per_gas: 1_000_000_000,
2171 max_priority_fee_per_gas: 1_000_000,
2172 gas_limit: 65_000,
2173 to: TxKind::Call(Address::ZERO),
2174 value: U256::ZERO,
2175 input: AlloyBytes::from(vec![0x09, 0x5e, 0xa7, 0xb3]),
2176 access_list: AccessList::default(),
2177 };
2178 let payload = ApprovalPayload {
2179 tx,
2180 token: bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2181 spender: bytes::Bytes::copy_from_slice(&[0x01; 20]),
2182 amount: num_bigint::BigUint::from(1_000_000u64),
2183 };
2184 SignedApproval::assemble(payload, Signature::test_signature())
2185 }
2186
2187 #[tokio::test]
2188 async fn execute_approval_broadcasts_and_polls() {
2189 let sender = Address::with_last_byte(0xab);
2190 let (client, asserter) =
2191 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
2192
2193 let tx_hash = alloy::primitives::B256::repeat_byte(0xef);
2195 asserter.push_success(&tx_hash);
2196
2197 asserter.push_success::<Option<()>>(&None);
2199 let receipt = alloy::rpc::types::TransactionReceipt {
2200 inner: alloy::consensus::ReceiptEnvelope::Eip1559(alloy::consensus::ReceiptWithBloom {
2201 receipt: alloy::consensus::Receipt::<alloy::primitives::Log> {
2202 status: alloy::consensus::Eip658Value::Eip658(true),
2203 cumulative_gas_used: 50_000,
2204 logs: vec![],
2205 },
2206 logs_bloom: alloy::primitives::Bloom::default(),
2207 }),
2208 transaction_hash: tx_hash,
2209 transaction_index: None,
2210 block_hash: None,
2211 block_number: None,
2212 gas_used: 45_000,
2213 effective_gas_price: 1_500_000_000,
2214 blob_gas_used: None,
2215 blob_gas_price: None,
2216 from: Address::ZERO,
2217 to: None,
2218 contract_address: None,
2219 };
2220 asserter.push_success(&receipt);
2221
2222 let approval = make_signed_approval();
2223 let tx_receipt = client
2224 .execute_approval(approval)
2225 .await
2226 .expect("execute_approval should succeed");
2227
2228 let mined = tx_receipt
2229 .await
2230 .expect("receipt should resolve");
2231
2232 assert_eq!(mined.tx_hash(), tx_hash);
2233 let expected_cost =
2234 num_bigint::BigUint::from(45_000u64) * num_bigint::BigUint::from(1_500_000_000u64);
2235 assert_eq!(mined.gas_cost(), &expected_cost);
2236 }
2237}