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 mapping::dto_to_batch_quote,
22 signing::{
23 compute_settled_amount, ApprovalPayload, ExecutionReceipt, FyndPayload, MinedTx,
24 SettledOrder, SignedApproval, SignedSwap, SwapPayload, TxReceipt,
25 },
26 types::{BackendKind, HealthStatus, InstanceInfo, Quote, QuoteParams, UserTransferType},
27};
28#[derive(Clone)]
38pub struct RetryConfig {
39 max_attempts: u32,
40 initial_backoff: Duration,
41 max_backoff: Duration,
42}
43
44impl RetryConfig {
45 pub fn new(max_attempts: u32, initial_backoff: Duration, max_backoff: Duration) -> Self {
51 Self { max_attempts, initial_backoff, max_backoff }
52 }
53
54 pub fn max_attempts(&self) -> u32 {
56 self.max_attempts
57 }
58
59 pub fn initial_backoff(&self) -> Duration {
61 self.initial_backoff
62 }
63
64 pub fn max_backoff(&self) -> Duration {
66 self.max_backoff
67 }
68}
69
70impl Default for RetryConfig {
71 fn default() -> Self {
72 Self {
73 max_attempts: 3,
74 initial_backoff: Duration::from_millis(100),
75 max_backoff: Duration::from_secs(2),
76 }
77 }
78}
79
80#[derive(Default)]
91pub struct SigningHints {
92 sender: Option<Address>,
93 nonce: Option<u64>,
94 max_fee_per_gas: Option<u128>,
95 max_priority_fee_per_gas: Option<u128>,
96 gas_limit: Option<u64>,
97 simulate: bool,
98}
99
100impl SigningHints {
101 pub fn with_sender(mut self, sender: Address) -> Self {
104 self.sender = Some(sender);
105 self
106 }
107
108 pub fn with_nonce(mut self, nonce: u64) -> Self {
110 self.nonce = Some(nonce);
111 self
112 }
113
114 pub fn with_max_fee_per_gas(mut self, max_fee_per_gas: u128) -> Self {
116 self.max_fee_per_gas = Some(max_fee_per_gas);
117 self
118 }
119
120 pub fn with_max_priority_fee_per_gas(mut self, max_priority_fee_per_gas: u128) -> Self {
122 self.max_priority_fee_per_gas = Some(max_priority_fee_per_gas);
123 self
124 }
125
126 pub fn with_gas_limit(mut self, gas_limit: u64) -> Self {
130 self.gas_limit = Some(gas_limit);
131 self
132 }
133
134 pub fn with_simulate(mut self, simulate: bool) -> Self {
137 self.simulate = simulate;
138 self
139 }
140
141 pub fn sender(&self) -> Option<Address> {
143 self.sender
144 }
145
146 pub fn nonce(&self) -> Option<u64> {
148 self.nonce
149 }
150
151 pub fn max_fee_per_gas(&self) -> Option<u128> {
153 self.max_fee_per_gas
154 }
155
156 pub fn max_priority_fee_per_gas(&self) -> Option<u128> {
158 self.max_priority_fee_per_gas
159 }
160
161 pub fn gas_limit(&self) -> Option<u64> {
163 self.gas_limit
164 }
165
166 pub fn simulate(&self) -> bool {
168 self.simulate
169 }
170}
171
172#[derive(Clone, Default)]
195pub struct StorageOverrides {
196 slots: HashMap<Bytes, HashMap<Bytes, Bytes>>,
198}
199
200impl StorageOverrides {
201 pub fn insert(&mut self, address: Bytes, slot: Bytes, value: Bytes) {
207 self.slots
208 .entry(address)
209 .or_default()
210 .insert(slot, value);
211 }
212
213 pub fn merge(&mut self, other: StorageOverrides) {
215 for (address, slots) in other.slots {
216 let entry = self.slots.entry(address).or_default();
217 entry.extend(slots);
218 }
219 }
220}
221
222fn storage_overrides_to_alloy(so: &StorageOverrides) -> Result<StateOverride, FyndError> {
223 let mut result = StateOverride::default();
224 for (addr_bytes, slot_map) in &so.slots {
225 let addr = mapping::bytes_to_alloy_address(addr_bytes)?;
226 let state_diff = slot_map
227 .iter()
228 .map(|(slot, val)| Ok((bytes_to_b256(slot)?, bytes_to_b256(val)?)))
229 .collect::<Result<alloy::primitives::map::B256HashMap<B256>, FyndError>>()?;
230 result.insert(addr, AccountOverride { state_diff: Some(state_diff), ..Default::default() });
231 }
232 Ok(result)
233}
234
235fn bytes_to_b256(b: &Bytes) -> Result<B256, FyndError> {
236 if b.len() != 32 {
237 return Err(FyndError::Protocol(format!("expected 32-byte slot, got {} bytes", b.len())));
238 }
239 let arr: [u8; 32] = b
240 .as_ref()
241 .try_into()
242 .expect("length checked above");
243 Ok(B256::from(arr))
244}
245
246pub struct ExecutionOptions {
252 pub dry_run: bool,
257 pub storage_overrides: Option<StorageOverrides>,
260 pub fetch_revert_reason: bool,
265}
266
267impl Default for ExecutionOptions {
268 fn default() -> Self {
269 Self { dry_run: false, storage_overrides: None, fetch_revert_reason: true }
270 }
271}
272
273pub struct ApprovalParams {
279 token: bytes::Bytes,
280 amount: num_bigint::BigUint,
281 transfer_type: UserTransferType,
282 check_allowance: bool,
283}
284
285impl ApprovalParams {
286 pub fn new(token: bytes::Bytes, amount: num_bigint::BigUint, check_allowance: bool) -> Self {
295 Self { token, amount, transfer_type: UserTransferType::TransferFrom, check_allowance }
296 }
297
298 pub fn with_transfer_type(mut self, transfer_type: UserTransferType) -> Self {
304 self.transfer_type = transfer_type;
305 self
306 }
307}
308
309mod erc20 {
314 use alloy::sol;
315
316 sol! {
317 function approve(address spender, uint256 amount) returns (bool);
318 function allowance(address owner, address spender) returns (uint256);
319 }
320}
321
322pub struct FyndClientBuilder {
334 base_url: String,
335 timeout: Duration,
336 retry: RetryConfig,
337 rpc_url: String,
338 submit_url: Option<String>,
339 sender: Option<Address>,
340}
341
342impl FyndClientBuilder {
343 pub fn new(base_url: impl Into<String>, rpc_url: impl Into<String>) -> Self {
349 Self {
350 base_url: base_url.into(),
351 timeout: Duration::from_secs(30),
352 retry: RetryConfig::default(),
353 rpc_url: rpc_url.into(),
354 submit_url: None,
355 sender: None,
356 }
357 }
358
359 pub fn with_timeout(mut self, timeout: Duration) -> Self {
361 self.timeout = timeout;
362 self
363 }
364
365 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
367 self.retry = retry;
368 self
369 }
370
371 pub fn with_submit_url(mut self, url: impl Into<String>) -> Self {
375 self.submit_url = Some(url.into());
376 self
377 }
378
379 pub fn with_sender(mut self, sender: Address) -> Self {
381 self.sender = Some(sender);
382 self
383 }
384
385 pub fn build_quote_only(self) -> Result<FyndClient, FyndError> {
393 let parsed_base = self
394 .base_url
395 .parse::<reqwest::Url>()
396 .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
397 let scheme = parsed_base.scheme();
398 if scheme != "http" && scheme != "https" {
399 return Err(FyndError::Config(format!(
400 "base URL must use http or https scheme, got '{scheme}'"
401 )));
402 }
403
404 let provider = ProviderBuilder::default().connect_http(parsed_base.clone());
407 let submit_provider = ProviderBuilder::default().connect_http(parsed_base);
408
409 let http = HttpClient::builder()
410 .timeout(self.timeout)
411 .build()
412 .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
413
414 Ok(FyndClient {
415 http,
416 base_url: self.base_url,
417 retry: self.retry,
418 chain_id: 1,
419 default_sender: self.sender,
420 provider,
421 submit_provider,
422 info_cache: tokio::sync::OnceCell::new(),
423 })
424 }
425
426 pub async fn build(self) -> Result<FyndClient, FyndError> {
431 let parsed_base = self
433 .base_url
434 .parse::<reqwest::Url>()
435 .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
436 let scheme = parsed_base.scheme();
437 if scheme != "http" && scheme != "https" {
438 return Err(FyndError::Config(format!(
439 "base URL must use http or https scheme, got '{scheme}'"
440 )));
441 }
442
443 let rpc_url = self
445 .rpc_url
446 .parse::<reqwest::Url>()
447 .map_err(|e| FyndError::Config(format!("invalid RPC URL: {e}")))?;
448 let provider = ProviderBuilder::default().connect_http(rpc_url);
449
450 let submit_url_str = self
451 .submit_url
452 .as_deref()
453 .unwrap_or(&self.rpc_url);
454 let submit_url = submit_url_str
455 .parse::<reqwest::Url>()
456 .map_err(|e| FyndError::Config(format!("invalid submit URL: {e}")))?;
457 let submit_provider = ProviderBuilder::default().connect_http(submit_url);
458
459 let chain_id = provider
461 .get_chain_id()
462 .await
463 .map_err(|e| FyndError::Config(format!("failed to fetch chain_id from RPC: {e}")))?;
464
465 let http = HttpClient::builder()
467 .timeout(self.timeout)
468 .build()
469 .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
470
471 Ok(FyndClient {
472 http,
473 base_url: self.base_url,
474 retry: self.retry,
475 chain_id,
476 default_sender: self.sender,
477 provider,
478 submit_provider,
479 info_cache: tokio::sync::OnceCell::new(),
480 })
481 }
482}
483
484pub struct FyndClient<P = RootProvider<Ethereum>>
495where
496 P: Provider<Ethereum> + Clone + Send + Sync + 'static,
497{
498 http: HttpClient,
499 base_url: String,
500 retry: RetryConfig,
501 chain_id: u64,
502 default_sender: Option<Address>,
503 provider: P,
504 submit_provider: P,
505 info_cache: tokio::sync::OnceCell<InstanceInfo>,
506}
507
508impl<P> FyndClient<P>
509where
510 P: Provider<Ethereum> + Clone + Send + Sync + 'static,
511{
512 #[doc(hidden)]
516 #[allow(clippy::too_many_arguments)]
517 pub fn new_with_providers(
518 http: HttpClient,
519 base_url: String,
520 retry: RetryConfig,
521 chain_id: u64,
522 default_sender: Option<Address>,
523 provider: P,
524 submit_provider: P,
525 ) -> Self {
526 Self {
527 http,
528 base_url,
529 retry,
530 chain_id,
531 default_sender,
532 provider,
533 submit_provider,
534 info_cache: tokio::sync::OnceCell::new(),
535 }
536 }
537
538 pub async fn quote(&self, params: QuoteParams) -> Result<Quote, FyndError> {
545 let token_out = params.order.token_out().clone();
546 let receiver = params
547 .order
548 .receiver()
549 .unwrap_or_else(|| params.order.sender())
550 .clone();
551 let dto_request = mapping::quote_params_to_dto(params)?;
552
553 let mut delay = self.retry.initial_backoff;
554 for attempt in 0..self.retry.max_attempts {
555 match self
556 .request_quote(&dto_request, token_out.clone(), receiver.clone())
557 .await
558 {
559 Ok(quote) => return Ok(quote),
560 Err(e) if e.is_retryable() && attempt + 1 < self.retry.max_attempts => {
561 tracing::debug!(attempt, "quote request failed, retrying");
562 tokio::time::sleep(delay).await;
563 delay = (delay * 2).min(self.retry.max_backoff);
564 }
565 Err(e) => return Err(e),
566 }
567 }
568 Err(FyndError::Protocol("retry loop exhausted without result".into()))
569 }
570
571 async fn request_quote(
572 &self,
573 dto_request: &fynd_rpc_types::QuoteRequest,
574 token_out: Bytes,
575 receiver: Bytes,
576 ) -> Result<Quote, FyndError> {
577 let url = format!("{}/v1/quote", self.base_url);
578 let response = self
579 .http
580 .post(&url)
581 .json(dto_request)
582 .send()
583 .await?;
584 if !response.status().is_success() {
585 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
586 return Err(mapping::dto_error_to_fynd(dto_err));
587 }
588 let dto_quote: fynd_rpc_types::Quote = response.json().await?;
589 let solve_time_ms = dto_quote.solve_time_ms();
590 let batch_quote = dto_to_batch_quote(dto_quote, token_out, receiver)?;
591
592 let mut quote = batch_quote
593 .quotes()
594 .first()
595 .cloned()
596 .ok_or_else(|| FyndError::Protocol("Received empty quote".into()))?;
597 quote.solve_time_ms = solve_time_ms;
598 Ok(quote)
599 }
600
601 pub async fn health(&self) -> Result<HealthStatus, FyndError> {
603 let url = format!("{}/v1/health", self.base_url);
604 let response = self.http.get(&url).send().await?;
605 let status = response.status();
606 let body = response.text().await?;
607 if let Ok(dh) = serde_json::from_str::<fynd_rpc_types::HealthStatus>(&body) {
610 return Ok(HealthStatus::from(dh));
611 }
612 if let Ok(dto_err) = serde_json::from_str::<fynd_rpc_types::ErrorResponse>(&body) {
613 return Err(mapping::dto_error_to_fynd(dto_err));
614 }
615 Err(FyndError::Protocol(format!("unexpected health response ({status}): {body}")))
616 }
617
618 pub async fn swap_payload(
630 &self,
631 quote: Quote,
632 hints: &SigningHints,
633 ) -> Result<SwapPayload, FyndError> {
634 match quote.backend() {
635 BackendKind::Fynd => {
636 self.fynd_swap_payload(quote, hints)
637 .await
638 }
639 BackendKind::Turbine => {
640 Err(FyndError::Protocol("Turbine signing not yet implemented".into()))
641 }
642 }
643 }
644
645 async fn fynd_swap_payload(
646 &self,
647 quote: Quote,
648 hints: &SigningHints,
649 ) -> Result<SwapPayload, FyndError> {
650 let sender = hints
652 .sender()
653 .or(self.default_sender)
654 .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
655
656 let nonce = match hints.nonce() {
658 Some(n) => n,
659 None => self
660 .provider
661 .get_transaction_count(sender)
662 .await
663 .map_err(FyndError::Provider)?,
664 };
665
666 let (max_fee_per_gas, max_priority_fee_per_gas) =
668 match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
669 (Some(mf), Some(mp)) => (mf, mp),
670 (mf, mp) => {
671 let est = self
672 .provider
673 .estimate_eip1559_fees()
674 .await
675 .map_err(FyndError::Provider)?;
676 (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
677 }
678 };
679
680 let tx_data = quote.transaction().ok_or_else(|| {
681 FyndError::Protocol(
682 "quote has no calldata; set encoding_options in QuoteOptions".into(),
683 )
684 })?;
685 let to_addr = mapping::bytes_to_alloy_address(tx_data.to())?;
686 let value = mapping::biguint_to_u256(tx_data.value());
687 let input = AlloyBytes::from(tx_data.data().to_vec());
688
689 let gas_limit = match hints.gas_limit() {
693 Some(g) => g,
694 None => {
695 let req = alloy::rpc::types::TransactionRequest::default()
696 .from(sender)
697 .to(to_addr)
698 .value(value)
699 .input(input.clone().into());
700 self.provider
701 .estimate_gas(req)
702 .await
703 .map_err(FyndError::Provider)?
704 }
705 };
706
707 let tx_eip1559 = TxEip1559 {
708 chain_id: self.chain_id,
709 nonce,
710 max_fee_per_gas,
711 max_priority_fee_per_gas,
712 gas_limit,
713 to: TxKind::Call(to_addr),
714 value,
715 input,
716 access_list: AccessList::default(),
717 };
718
719 if hints.simulate() {
721 let req = alloy::rpc::types::TransactionRequest::from_transaction_with_sender(
722 tx_eip1559.clone(),
723 sender,
724 );
725 self.provider
726 .call(req)
727 .await
728 .map_err(|e| {
729 FyndError::SimulationFailed(format!("transaction simulation failed: {e}"))
730 })?;
731 }
732
733 let tx = TypedTransaction::Eip1559(tx_eip1559);
734 Ok(SwapPayload::Fynd(Box::new(FyndPayload::new(quote, tx))))
735 }
736
737 pub async fn execute_swap(
748 &self,
749 order: SignedSwap,
750 options: &ExecutionOptions,
751 ) -> Result<ExecutionReceipt, FyndError> {
752 let (payload, signature) = order.into_parts();
753 let (quote, tx) = payload.into_fynd_parts()?;
754
755 let TypedTransaction::Eip1559(tx_eip1559) = tx else {
756 return Err(FyndError::Protocol(
757 "only EIP-1559 transactions are supported for execution".into(),
758 ));
759 };
760
761 if options.dry_run {
762 return self
763 .dry_run_execute(tx_eip1559, options)
764 .await;
765 }
766
767 let tx_hash = self
768 .send_raw(tx_eip1559.clone(), signature)
769 .await?;
770
771 let token_out_addr = mapping::bytes_to_alloy_address(quote.token_out())?;
772 let receiver_addr = mapping::bytes_to_alloy_address(quote.receiver())?;
773 let provider = self.submit_provider.clone();
774 let fetch_revert = options.fetch_revert_reason;
775 let fallback_to = match tx_eip1559.to {
779 TxKind::Call(addr) => addr,
780 TxKind::Create => Address::ZERO,
781 };
782 let fallback_req = TransactionRequest::default()
783 .to(fallback_to)
784 .value(tx_eip1559.value)
785 .input(tx_eip1559.input.clone().into());
786
787 Ok(ExecutionReceipt::Transaction(Box::pin(async move {
788 loop {
789 match provider
790 .get_transaction_receipt(tx_hash)
791 .await
792 .map_err(FyndError::Provider)?
793 {
794 Some(receipt) => {
795 if !receipt.status() {
796 let reason = if fetch_revert {
797 let trace: Result<serde_json::Value, _> = provider
799 .raw_request(
800 std::borrow::Cow::Borrowed("debug_traceTransaction"),
801 (tx_hash, serde_json::json!({})),
802 )
803 .await;
804 match trace {
805 Ok(t) => {
806 let hex_str = t
807 .get("returnValue")
808 .and_then(|v| v.as_str())
809 .unwrap_or("");
810 match alloy::primitives::hex::decode(
811 hex_str.trim_start_matches("0x"),
812 ) {
813 Ok(b) => decode_revert_bytes(&b),
814 Err(_) => format!(
815 "{tx_hash:#x} reverted (return value: {hex_str})"
816 ),
817 }
818 }
819 Err(_) => {
820 tracing::warn!(
821 tx = ?tx_hash,
822 "debug_traceTransaction unavailable; replaying via \
823 eth_call — block state may differ"
824 );
825 match provider.call(fallback_req).await {
826 Err(e) => e.to_string(),
827 Ok(_) => {
828 format!("{tx_hash:#x} reverted (no reason)")
829 }
830 }
831 }
832 }
833 } else {
834 format!("{tx_hash:#x}")
835 };
836 return Err(FyndError::TransactionReverted(reason));
837 }
838 let settled_amount =
839 compute_settled_amount(&receipt, &token_out_addr, &receiver_addr);
840 let gas_cost = BigUint::from(receipt.gas_used) *
841 BigUint::from(receipt.effective_gas_price);
842 return Ok(SettledOrder::new(settled_amount, gas_cost));
843 }
844 None => tokio::time::sleep(Duration::from_secs(2)).await,
845 }
846 }
847 })))
848 }
849
850 pub async fn info(&self) -> Result<&InstanceInfo, FyndError> {
855 self.info_cache
856 .get_or_try_init(|| self.fetch_info())
857 .await
858 }
859
860 async fn fetch_info(&self) -> Result<InstanceInfo, FyndError> {
861 let url = format!("{}/v1/info", self.base_url);
862 let response = self.http.get(&url).send().await?;
863 if !response.status().is_success() {
864 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
865 return Err(mapping::dto_error_to_fynd(dto_err));
866 }
867 let dto_info: fynd_rpc_types::InstanceInfo = response.json().await?;
868 dto_info.try_into()
869 }
870
871 pub async fn approval(
883 &self,
884 params: &ApprovalParams,
885 hints: &SigningHints,
886 ) -> Result<Option<ApprovalPayload>, FyndError> {
887 use alloy::sol_types::SolCall;
888
889 let info = self.info().await?;
890 let spender_addr = match params.transfer_type {
891 UserTransferType::TransferFrom => {
892 mapping::bytes_to_alloy_address(info.router_address())?
893 }
894 UserTransferType::TransferFromPermit2 => {
895 mapping::bytes_to_alloy_address(info.permit2_address())?
896 }
897 UserTransferType::UseVaultsFunds => return Ok(None),
898 };
899
900 let sender = hints
901 .sender()
902 .or(self.default_sender)
903 .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
904
905 let token_addr = mapping::bytes_to_alloy_address(¶ms.token)?;
906 let amount_u256 = mapping::biguint_to_u256(¶ms.amount);
907
908 if params.check_allowance {
910 let call_data =
911 erc20::allowanceCall { owner: sender, spender: spender_addr }.abi_encode();
912 let req = alloy::rpc::types::TransactionRequest {
913 to: Some(alloy::primitives::TxKind::Call(token_addr)),
914 input: alloy::rpc::types::TransactionInput::new(AlloyBytes::from(call_data)),
915 ..Default::default()
916 };
917 let result = self
918 .provider
919 .call(req)
920 .await
921 .map_err(|e| FyndError::Protocol(format!("allowance call failed: {e}")))?;
922 let current_allowance = if result.len() >= 32 {
923 alloy::primitives::U256::from_be_slice(&result[0..32])
924 } else {
925 alloy::primitives::U256::ZERO
926 };
927 if current_allowance >= amount_u256 {
928 return Ok(None);
929 }
930 }
931
932 let nonce = match hints.nonce() {
934 Some(n) => n,
935 None => self
936 .provider
937 .get_transaction_count(sender)
938 .await
939 .map_err(FyndError::Provider)?,
940 };
941
942 let (max_fee_per_gas, max_priority_fee_per_gas) =
944 match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
945 (Some(mf), Some(mp)) => (mf, mp),
946 (mf, mp) => {
947 let est = self
948 .provider
949 .estimate_eip1559_fees()
950 .await
951 .map_err(FyndError::Provider)?;
952 (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
953 }
954 };
955
956 let calldata =
957 erc20::approveCall { spender: spender_addr, amount: amount_u256 }.abi_encode();
958
959 let gas_limit = match hints.gas_limit() {
961 Some(g) => g,
962 None => {
963 let req = alloy::rpc::types::TransactionRequest::default()
964 .from(sender)
965 .to(token_addr)
966 .input(AlloyBytes::from(calldata.clone()).into());
967 self.provider
968 .estimate_gas(req)
969 .await
970 .map_err(FyndError::Provider)?
971 }
972 };
973
974 let tx = TxEip1559 {
975 chain_id: self.chain_id,
976 nonce,
977 max_fee_per_gas,
978 max_priority_fee_per_gas,
979 gas_limit,
980 to: alloy::primitives::TxKind::Call(token_addr),
981 value: alloy::primitives::U256::ZERO,
982 input: AlloyBytes::from(calldata),
983 access_list: alloy::eips::eip2930::AccessList::default(),
984 };
985
986 let spender = bytes::Bytes::copy_from_slice(spender_addr.as_slice());
987 Ok(Some(ApprovalPayload {
988 tx,
989 token: params.token.clone(),
990 spender,
991 amount: params.amount.clone(),
992 }))
993 }
994
995 pub async fn execute_approval(&self, approval: SignedApproval) -> Result<TxReceipt, FyndError> {
1001 let (payload, signature) = approval.into_parts();
1002 let fallback_req = TransactionRequest::default()
1003 .to(mapping::bytes_to_alloy_address(&payload.token)?)
1004 .input(payload.tx.input.clone().into());
1005 let tx_hash = self
1006 .send_raw(payload.tx, signature)
1007 .await?;
1008 let provider = self.submit_provider.clone();
1009
1010 Ok(TxReceipt::Pending(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 trace: Result<serde_json::Value, _> = provider
1020 .raw_request(
1021 std::borrow::Cow::Borrowed("debug_traceTransaction"),
1022 (tx_hash, serde_json::json!({})),
1023 )
1024 .await;
1025 let reason = match trace {
1026 Ok(t) => {
1027 let hex_str = t
1028 .get("returnValue")
1029 .and_then(|v| v.as_str())
1030 .unwrap_or("");
1031 match alloy::primitives::hex::decode(
1032 hex_str.trim_start_matches("0x"),
1033 ) {
1034 Ok(b) => decode_revert_bytes(&b),
1035 Err(_) => format!(
1036 "{tx_hash:#x} reverted (return value: {hex_str})"
1037 ),
1038 }
1039 }
1040 Err(_) => {
1041 tracing::warn!(
1042 tx = ?tx_hash,
1043 "debug_traceTransaction unavailable; replaying via \
1044 eth_call — block state may differ"
1045 );
1046 match provider.call(fallback_req).await {
1047 Err(e) => e.to_string(),
1048 Ok(_) => format!("{tx_hash:#x} reverted (no reason)"),
1049 }
1050 }
1051 };
1052 return Err(FyndError::TransactionReverted(reason));
1053 }
1054 let gas_cost = BigUint::from(receipt.gas_used) *
1055 BigUint::from(receipt.effective_gas_price);
1056 return Ok(MinedTx::new(tx_hash, gas_cost));
1057 }
1058 None => tokio::time::sleep(Duration::from_secs(2)).await,
1059 }
1060 }
1061 })))
1062 }
1063
1064 async fn send_raw(
1066 &self,
1067 tx: TxEip1559,
1068 signature: alloy::primitives::Signature,
1069 ) -> Result<B256, FyndError> {
1070 use alloy::eips::eip2718::Encodable2718;
1071 let envelope = TypedTransaction::Eip1559(tx).into_envelope(signature);
1072 let raw = envelope.encoded_2718();
1073 let pending = self
1074 .submit_provider
1075 .send_raw_transaction(&raw)
1076 .await
1077 .map_err(FyndError::Provider)?;
1078 Ok(*pending.tx_hash())
1079 }
1080
1081 async fn dry_run_execute(
1082 &self,
1083 tx_eip1559: TxEip1559,
1084 options: &ExecutionOptions,
1085 ) -> Result<ExecutionReceipt, FyndError> {
1086 let mut req: TransactionRequest = tx_eip1559.clone().into();
1087 if let Some(sender) = self.default_sender {
1088 req.from = Some(sender);
1089 }
1090 let overrides = options
1091 .storage_overrides
1092 .as_ref()
1093 .map(storage_overrides_to_alloy)
1094 .transpose()?;
1095
1096 let return_data = self
1097 .provider
1098 .call(req.clone())
1099 .overrides_opt(overrides.clone())
1100 .await
1101 .map_err(|e| FyndError::SimulationFailed(format!("dry run simulation failed: {e}")))?;
1102
1103 let gas_used = self
1104 .provider
1105 .estimate_gas(req)
1106 .overrides_opt(overrides)
1107 .await
1108 .map_err(|e| {
1109 FyndError::SimulationFailed(format!("dry run gas estimation failed: {e}"))
1110 })?;
1111
1112 let settled_amount = if return_data.len() >= 32 {
1113 Some(BigUint::from_bytes_be(&return_data[0..32]))
1114 } else {
1115 None
1116 };
1117 let gas_cost = BigUint::from(gas_used) * BigUint::from(tx_eip1559.max_fee_per_gas);
1118 let settled = SettledOrder::new(settled_amount, gas_cost);
1119
1120 Ok(ExecutionReceipt::Transaction(Box::pin(async move { Ok(settled) })))
1121 }
1122}
1123
1124fn decode_revert_bytes(data: &[u8]) -> String {
1129 const SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0];
1131 if data.len() >= 68 && data[..4] == SELECTOR {
1132 let str_len = u64::from_be_bytes(
1133 data[60..68]
1134 .try_into()
1135 .unwrap_or([0u8; 8]),
1136 ) as usize;
1137 if data.len() >= 68 + str_len {
1138 if let Ok(s) = std::str::from_utf8(&data[68..68 + str_len]) {
1139 return s.to_owned();
1140 }
1141 }
1142 }
1143 if data.is_empty() {
1144 "empty revert data".to_owned()
1145 } else {
1146 format!("0x{}", alloy::primitives::hex::encode(data))
1147 }
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152 use std::time::Duration;
1153
1154 use super::*;
1155
1156 #[test]
1157 fn retry_config_default_values() {
1158 let config = RetryConfig::default();
1159 assert_eq!(config.max_attempts(), 3);
1160 assert_eq!(config.initial_backoff(), Duration::from_millis(100));
1161 assert_eq!(config.max_backoff(), Duration::from_secs(2));
1162 }
1163
1164 #[test]
1165 fn signing_hints_default_all_none_and_no_simulate() {
1166 let hints = SigningHints::default();
1167 assert!(hints.sender().is_none());
1168 assert!(hints.nonce().is_none());
1169 assert!(!hints.simulate());
1170 }
1171
1172 fn make_test_client(
1181 base_url: String,
1182 retry: RetryConfig,
1183 default_sender: Option<Address>,
1184 ) -> (FyndClient<alloy::providers::RootProvider<Ethereum>>, alloy::providers::mock::Asserter)
1185 {
1186 use alloy::providers::{mock::Asserter, ProviderBuilder};
1187
1188 let asserter = Asserter::new();
1189 let provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1190 let submit_provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1191
1192 let http = HttpClient::builder()
1193 .timeout(Duration::from_secs(5))
1194 .build()
1195 .expect("reqwest client");
1196
1197 let client = FyndClient::new_with_providers(
1198 http,
1199 base_url,
1200 retry,
1201 1,
1202 default_sender,
1203 provider,
1204 submit_provider,
1205 );
1206
1207 (client, asserter)
1208 }
1209
1210 fn make_order_quote() -> crate::types::Quote {
1212 use num_bigint::BigUint;
1213
1214 use crate::types::{BackendKind, BlockInfo, QuoteStatus, Transaction};
1215
1216 let tx = Transaction::new(
1217 bytes::Bytes::copy_from_slice(&[0x01; 20]),
1218 BigUint::ZERO,
1219 vec![0x12, 0x34],
1220 );
1221
1222 crate::types::Quote::new(
1223 "test-order-id".to_string(),
1224 QuoteStatus::Success,
1225 BackendKind::Fynd,
1226 None,
1227 BigUint::from(1_000_000u64),
1228 BigUint::from(990_000u64),
1229 BigUint::from(50_000u64),
1230 BigUint::from(940_000u64),
1231 Some(10),
1232 BlockInfo::new(1_234_567, "0xabcdef".to_string(), 1_700_000_000),
1233 bytes::Bytes::copy_from_slice(&[0xbb; 20]),
1234 bytes::Bytes::copy_from_slice(&[0xcc; 20]),
1235 Some(tx),
1236 None,
1237 )
1238 }
1239
1240 #[tokio::test]
1245 async fn quote_returns_parsed_quote_on_success() {
1246 use wiremock::{
1247 matchers::{method, path},
1248 Mock, MockServer, ResponseTemplate,
1249 };
1250
1251 let server = MockServer::start().await;
1252 let body = serde_json::json!({
1253 "orders": [{
1254 "order_id": "abc-123",
1255 "status": "success",
1256 "amount_in": "1000000",
1257 "amount_out": "990000",
1258 "gas_estimate": "50000",
1259 "amount_out_net_gas": "940000",
1260 "price_impact_bps": 10,
1261 "block": {
1262 "number": 1234567,
1263 "hash": "0xabcdef",
1264 "timestamp": 1700000000
1265 }
1266 }],
1267 "total_gas_estimate": "50000",
1268 "solve_time_ms": 42
1269 });
1270
1271 Mock::given(method("POST"))
1272 .and(path("/v1/quote"))
1273 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1274 .expect(1)
1275 .mount(&server)
1276 .await;
1277
1278 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1279
1280 let params = make_quote_params();
1281 let quote = client
1282 .quote(params)
1283 .await
1284 .expect("quote should succeed");
1285
1286 assert_eq!(quote.order_id(), "abc-123");
1287 assert_eq!(quote.amount_out(), &num_bigint::BigUint::from(990_000u64));
1288 }
1289
1290 #[tokio::test]
1291 async fn quote_returns_api_error_on_non_retryable_server_error() {
1292 use wiremock::{
1293 matchers::{method, path},
1294 Mock, MockServer, ResponseTemplate,
1295 };
1296
1297 use crate::error::ErrorCode;
1298
1299 let server = MockServer::start().await;
1300
1301 Mock::given(method("POST"))
1302 .and(path("/v1/quote"))
1303 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1304 "error": "bad input",
1305 "code": "BAD_REQUEST"
1306 })))
1307 .expect(1)
1308 .mount(&server)
1309 .await;
1310
1311 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1312
1313 let err = client
1314 .quote(make_quote_params())
1315 .await
1316 .unwrap_err();
1317 assert!(
1318 matches!(err, FyndError::Api { code: ErrorCode::BadRequest, .. }),
1319 "expected BadRequest, got {err:?}"
1320 );
1321 }
1322
1323 #[tokio::test]
1324 async fn quote_retries_on_retryable_error_then_succeeds() {
1325 use wiremock::{
1326 matchers::{method, path},
1327 Mock, MockServer, ResponseTemplate,
1328 };
1329
1330 let server = MockServer::start().await;
1331
1332 Mock::given(method("POST"))
1334 .and(path("/v1/quote"))
1335 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1336 "error": "queue full",
1337 "code": "QUEUE_FULL"
1338 })))
1339 .up_to_n_times(1)
1340 .mount(&server)
1341 .await;
1342
1343 let success_body = serde_json::json!({
1345 "orders": [{
1346 "order_id": "retry-order",
1347 "status": "success",
1348 "amount_in": "1000000",
1349 "amount_out": "990000",
1350 "gas_estimate": "50000",
1351 "amount_out_net_gas": "940000",
1352 "price_impact_bps": null,
1353 "block": {
1354 "number": 1234568,
1355 "hash": "0xabcdef01",
1356 "timestamp": 1700000012
1357 }
1358 }],
1359 "total_gas_estimate": "50000",
1360 "solve_time_ms": 10
1361 });
1362 Mock::given(method("POST"))
1363 .and(path("/v1/quote"))
1364 .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
1365 .up_to_n_times(1)
1366 .mount(&server)
1367 .await;
1368
1369 let retry = RetryConfig::new(3, Duration::from_millis(1), Duration::from_millis(10));
1370 let (client, _asserter) = make_test_client(server.uri(), retry, None);
1371
1372 let quote = client
1373 .quote(make_quote_params())
1374 .await
1375 .expect("should succeed after retry");
1376 assert_eq!(quote.order_id(), "retry-order");
1377 }
1378
1379 #[tokio::test]
1380 async fn quote_exhausts_retries_and_returns_last_error() {
1381 use wiremock::{
1382 matchers::{method, path},
1383 Mock, MockServer, ResponseTemplate,
1384 };
1385
1386 use crate::error::ErrorCode;
1387
1388 let server = MockServer::start().await;
1389
1390 Mock::given(method("POST"))
1391 .and(path("/v1/quote"))
1392 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1393 "error": "queue full",
1394 "code": "QUEUE_FULL"
1395 })))
1396 .mount(&server)
1397 .await;
1398
1399 let retry = RetryConfig::new(2, Duration::from_millis(1), Duration::from_millis(10));
1400 let (client, _asserter) = make_test_client(server.uri(), retry, None);
1401
1402 let err = client
1403 .quote(make_quote_params())
1404 .await
1405 .unwrap_err();
1406 assert!(
1407 matches!(err, FyndError::Api { code: ErrorCode::ServiceUnavailable, .. }),
1408 "expected ServiceUnavailable after retry exhaustion, got {err:?}"
1409 );
1410 }
1411
1412 #[tokio::test]
1413 async fn quote_returns_error_on_malformed_response() {
1414 use wiremock::{
1415 matchers::{method, path},
1416 Mock, MockServer, ResponseTemplate,
1417 };
1418
1419 let server = MockServer::start().await;
1420
1421 Mock::given(method("POST"))
1422 .and(path("/v1/quote"))
1423 .respond_with(
1424 ResponseTemplate::new(200).set_body_json(serde_json::json!({"garbage": true})),
1425 )
1426 .mount(&server)
1427 .await;
1428
1429 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1430
1431 let err = client
1432 .quote(make_quote_params())
1433 .await
1434 .unwrap_err();
1435 assert!(
1437 matches!(err, FyndError::Http(_)),
1438 "expected Http deserialization error, got {err:?}"
1439 );
1440 }
1441
1442 #[tokio::test]
1447 async fn health_returns_status_on_success() {
1448 use wiremock::{
1449 matchers::{method, path},
1450 Mock, MockServer, ResponseTemplate,
1451 };
1452
1453 let server = MockServer::start().await;
1454
1455 Mock::given(method("GET"))
1456 .and(path("/v1/health"))
1457 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1458 "healthy": true,
1459 "last_update_ms": 100,
1460 "num_solver_pools": 5
1461 })))
1462 .expect(1)
1463 .mount(&server)
1464 .await;
1465
1466 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1467
1468 let status = client
1469 .health()
1470 .await
1471 .expect("health should succeed");
1472 assert!(status.healthy());
1473 assert_eq!(status.last_update_ms(), 100);
1474 assert_eq!(status.num_solver_pools(), 5);
1475 }
1476
1477 #[tokio::test]
1478 async fn health_returns_error_on_server_failure() {
1479 use wiremock::{
1480 matchers::{method, path},
1481 Mock, MockServer, ResponseTemplate,
1482 };
1483
1484 let server = MockServer::start().await;
1485
1486 Mock::given(method("GET"))
1487 .and(path("/v1/health"))
1488 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1489 "error": "service unavailable",
1490 "code": "NOT_READY"
1491 })))
1492 .expect(1)
1493 .mount(&server)
1494 .await;
1495
1496 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1497
1498 let err = client.health().await.unwrap_err();
1499 assert!(matches!(err, FyndError::Api { .. }), "expected Api error, got {err:?}");
1500 }
1501
1502 #[tokio::test]
1507 async fn swap_payload_uses_hints_when_all_provided() {
1508 let sender = Address::with_last_byte(0xab);
1509 let (client, _asserter) =
1510 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1511
1512 let quote = make_order_quote();
1513 let hints = SigningHints {
1514 sender: Some(sender),
1515 nonce: Some(5),
1516 max_fee_per_gas: Some(1_000_000_000),
1517 max_priority_fee_per_gas: Some(1_000_000),
1518 gas_limit: Some(100_000),
1519 simulate: false,
1520 };
1521
1522 let payload = client
1523 .swap_payload(quote, &hints)
1524 .await
1525 .expect("swap_payload should succeed");
1526
1527 let SwapPayload::Fynd(fynd) = payload else {
1528 panic!("expected Fynd payload");
1529 };
1530 let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1531 panic!("expected EIP-1559 transaction");
1532 };
1533 assert_eq!(tx.nonce, 5);
1534 assert_eq!(tx.max_fee_per_gas, 1_000_000_000);
1535 assert_eq!(tx.max_priority_fee_per_gas, 1_000_000);
1536 assert_eq!(tx.gas_limit, 100_000);
1537 }
1538
1539 #[tokio::test]
1540 async fn swap_payload_fetches_nonce_and_fees_when_hints_absent() {
1541 let sender = Address::with_last_byte(0xde);
1542 let (client, asserter) =
1543 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1544
1545 asserter.push_success(&7u64);
1547 let fee_history = serde_json::json!({
1551 "oldestBlock": "0x1",
1552 "baseFeePerGas": ["0x3b9aca00", "0x3b9aca00"],
1553 "gasUsedRatio": [0.5],
1554 "reward": [["0xf4240", "0x1e8480"]]
1555 });
1556 asserter.push_success(&fee_history);
1557 asserter.push_success(&150_000u64);
1559
1560 let quote = make_order_quote();
1561 let hints = SigningHints::default();
1562
1563 let payload = client
1564 .swap_payload(quote, &hints)
1565 .await
1566 .expect("swap_payload should succeed");
1567
1568 let SwapPayload::Fynd(fynd) = payload else {
1569 panic!("expected Fynd payload");
1570 };
1571 let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1572 panic!("expected EIP-1559 transaction");
1573 };
1574 assert_eq!(tx.nonce, 7, "nonce should come from mock");
1575 assert_eq!(tx.gas_limit, 150_000, "gas limit should come from eth_estimateGas");
1576 }
1577
1578 #[tokio::test]
1579 async fn swap_payload_returns_config_error_when_no_sender() {
1580 let (client, _asserter) =
1582 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1583
1584 let quote = make_order_quote();
1585 let hints = SigningHints::default(); let err = client
1588 .swap_payload(quote, &hints)
1589 .await
1590 .unwrap_err();
1591
1592 assert!(matches!(err, FyndError::Config(_)), "expected Config error, got {err:?}");
1593 }
1594
1595 #[tokio::test]
1596 async fn swap_payload_with_simulate_true_calls_eth_call_successfully() {
1597 let sender = Address::with_last_byte(0xab);
1598 let (client, asserter) =
1599 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1600
1601 let quote = make_order_quote();
1602 let hints = SigningHints {
1603 sender: Some(sender),
1604 nonce: Some(1),
1605 max_fee_per_gas: Some(1_000_000_000),
1606 max_priority_fee_per_gas: Some(1_000_000),
1607 gas_limit: Some(100_000),
1608 simulate: true,
1609 };
1610
1611 asserter.push_success(&alloy::primitives::Bytes::new());
1613
1614 let payload = client
1615 .swap_payload(quote, &hints)
1616 .await
1617 .expect("swap_payload with simulate=true should succeed");
1618
1619 assert!(matches!(payload, SwapPayload::Fynd(_)));
1620 }
1621
1622 #[tokio::test]
1623 async fn swap_payload_with_simulate_true_returns_simulation_failed_on_revert() {
1624 let sender = Address::with_last_byte(0xab);
1625 let (client, asserter) =
1626 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1627
1628 let quote = make_order_quote();
1629 let hints = SigningHints {
1630 sender: Some(sender),
1631 nonce: Some(1),
1632 max_fee_per_gas: Some(1_000_000_000),
1633 max_priority_fee_per_gas: Some(1_000_000),
1634 gas_limit: Some(100_000),
1635 simulate: true,
1636 };
1637
1638 asserter.push_failure_msg("execution reverted");
1640
1641 let err = client
1642 .swap_payload(quote, &hints)
1643 .await
1644 .unwrap_err();
1645
1646 assert!(
1647 matches!(err, FyndError::SimulationFailed(_)),
1648 "expected SimulationFailed, got {err:?}"
1649 );
1650 }
1651
1652 fn make_signed_swap() -> SignedSwap {
1661 use alloy::{
1662 eips::eip2930::AccessList,
1663 primitives::{Bytes as AlloyBytes, Signature, TxKind, U256},
1664 };
1665
1666 use crate::signing::FyndPayload;
1667
1668 let quote = make_order_quote();
1669 let tx = TxEip1559 {
1670 chain_id: 1,
1671 nonce: 1,
1672 max_fee_per_gas: 1_000_000_000,
1673 max_priority_fee_per_gas: 1_000_000,
1674 gas_limit: 100_000,
1675 to: TxKind::Call(Address::ZERO),
1676 value: U256::ZERO,
1677 input: AlloyBytes::new(),
1678 access_list: AccessList::default(),
1679 };
1680 let payload =
1681 SwapPayload::Fynd(Box::new(FyndPayload::new(quote, TypedTransaction::Eip1559(tx))));
1682 SignedSwap::assemble(payload, Signature::test_signature())
1683 }
1684
1685 #[tokio::test]
1686 async fn execute_dry_run_returns_settled_order_without_broadcast() {
1687 let sender = Address::with_last_byte(0xab);
1688 let (client, asserter) =
1689 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1690
1691 let mut amount_bytes = vec![0u8; 32];
1693 amount_bytes[24..32].copy_from_slice(&990_000u64.to_be_bytes());
1694 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1695 asserter.push_success(&50_000u64); let order = make_signed_swap();
1698 let opts =
1699 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1700 let receipt = client
1701 .execute_swap(order, &opts)
1702 .await
1703 .expect("execute should succeed");
1704 let settled = receipt
1705 .await
1706 .expect("should resolve immediately");
1707
1708 assert_eq!(settled.settled_amount(), Some(&num_bigint::BigUint::from(990_000u64)),);
1709 let expected_gas_cost =
1710 num_bigint::BigUint::from(50_000u64) * num_bigint::BigUint::from(1_000_000_000u64);
1711 assert_eq!(settled.gas_cost(), &expected_gas_cost);
1712 }
1713
1714 #[tokio::test]
1715 async fn execute_dry_run_with_storage_overrides_succeeds() {
1716 let sender = Address::with_last_byte(0xab);
1717 let (client, asserter) =
1718 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1719
1720 let mut overrides = StorageOverrides::default();
1721 overrides.insert(
1722 bytes::Bytes::copy_from_slice(&[0u8; 20]),
1723 bytes::Bytes::copy_from_slice(&[0u8; 32]),
1724 bytes::Bytes::copy_from_slice(&[1u8; 32]),
1725 );
1726
1727 let mut amount_bytes = vec![0u8; 32];
1728 amount_bytes[24..32].copy_from_slice(&100u64.to_be_bytes());
1729 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1730 asserter.push_success(&21_000u64);
1731
1732 let order = make_signed_swap();
1733 let opts = ExecutionOptions {
1734 dry_run: true,
1735 storage_overrides: Some(overrides),
1736 fetch_revert_reason: false,
1737 };
1738 let receipt = client
1739 .execute_swap(order, &opts)
1740 .await
1741 .expect("execute with overrides should succeed");
1742 receipt.await.expect("should resolve");
1743 }
1744
1745 #[tokio::test]
1746 async fn execute_dry_run_returns_simulation_failed_on_call_error() {
1747 let sender = Address::with_last_byte(0xab);
1748 let (client, asserter) =
1749 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1750
1751 asserter.push_failure_msg("execution reverted");
1752
1753 let order = make_signed_swap();
1754 let opts =
1755 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1756 let result = client.execute_swap(order, &opts).await;
1757 let err = match result {
1758 Err(e) => e,
1759 Ok(_) => panic!("expected SimulationFailed error"),
1760 };
1761
1762 assert!(
1763 matches!(err, FyndError::SimulationFailed(_)),
1764 "expected SimulationFailed, got {err:?}"
1765 );
1766 }
1767
1768 #[tokio::test]
1769 async fn execute_dry_run_with_empty_return_data_has_no_settled_amount() {
1770 let sender = Address::with_last_byte(0xab);
1771 let (client, asserter) =
1772 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1773
1774 asserter.push_success(&alloy::primitives::Bytes::new());
1775 asserter.push_success(&21_000u64);
1776
1777 let order = make_signed_swap();
1778 let opts =
1779 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1780 let receipt = client
1781 .execute_swap(order, &opts)
1782 .await
1783 .expect("execute should succeed");
1784 let settled = receipt.await.expect("should resolve");
1785
1786 assert!(
1787 settled.settled_amount().is_none(),
1788 "empty return data should yield None settled_amount"
1789 );
1790 }
1791
1792 #[tokio::test]
1793 async fn swap_payload_returns_protocol_error_when_no_transaction() {
1794 use crate::types::{BackendKind, BlockInfo, QuoteStatus};
1795
1796 let sender = Address::with_last_byte(0xab);
1797 let (client, _asserter) =
1798 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1799
1800 let quote = crate::types::Quote::new(
1802 "no-tx".to_string(),
1803 QuoteStatus::Success,
1804 BackendKind::Fynd,
1805 None,
1806 num_bigint::BigUint::from(1_000u64),
1807 num_bigint::BigUint::from(990u64),
1808 num_bigint::BigUint::from(50_000u64),
1809 num_bigint::BigUint::from(940u64),
1810 None,
1811 BlockInfo::new(1, "0xabc".to_string(), 0),
1812 bytes::Bytes::copy_from_slice(&[0xbb; 20]),
1813 bytes::Bytes::copy_from_slice(&[0xcc; 20]),
1814 None,
1815 None,
1816 );
1817 let hints = SigningHints {
1818 sender: Some(sender),
1819 nonce: Some(1),
1820 max_fee_per_gas: Some(1_000_000_000),
1821 max_priority_fee_per_gas: Some(1_000_000),
1822 gas_limit: Some(100_000),
1823 simulate: false,
1824 };
1825
1826 let err = client
1827 .swap_payload(quote, &hints)
1828 .await
1829 .unwrap_err();
1830
1831 assert!(
1832 matches!(err, FyndError::Protocol(_)),
1833 "expected Protocol error when quote has no transaction, got {err:?}"
1834 );
1835 }
1836
1837 fn make_quote_params() -> QuoteParams {
1842 use crate::types::{Order, OrderSide, QuoteOptions};
1843
1844 let token_in = bytes::Bytes::copy_from_slice(&[0xaa; 20]);
1845 let token_out = bytes::Bytes::copy_from_slice(&[0xbb; 20]);
1846 let sender = bytes::Bytes::copy_from_slice(&[0xcc; 20]);
1847
1848 let order = Order::new(
1849 token_in,
1850 token_out,
1851 num_bigint::BigUint::from(1_000_000u64),
1852 OrderSide::Sell,
1853 sender,
1854 None,
1855 );
1856
1857 QuoteParams::new(order, QuoteOptions::default())
1858 }
1859
1860 fn make_info_body() -> serde_json::Value {
1865 serde_json::json!({
1866 "chain_id": 1,
1867 "router_address": "0x0101010101010101010101010101010101010101",
1868 "permit2_address": "0x0202020202020202020202020202020202020202"
1869 })
1870 }
1871
1872 #[tokio::test]
1873 async fn info_fetches_and_caches() {
1874 use wiremock::{
1875 matchers::{method, path},
1876 Mock, MockServer, ResponseTemplate,
1877 };
1878
1879 let server = MockServer::start().await;
1880
1881 Mock::given(method("GET"))
1882 .and(path("/v1/info"))
1883 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
1884 .expect(1) .mount(&server)
1886 .await;
1887
1888 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1889
1890 let info1 = client
1891 .info()
1892 .await
1893 .expect("first info call should succeed");
1894 let info2 = client
1895 .info()
1896 .await
1897 .expect("second info call should use cache");
1898
1899 assert_eq!(info1.chain_id(), 1);
1900 assert_eq!(info2.chain_id(), 1);
1901 assert_eq!(info1.router_address().as_ref(), &[0x01u8; 20]);
1902 assert_eq!(info1.permit2_address().as_ref(), &[0x02u8; 20]);
1903 }
1905
1906 #[tokio::test]
1911 async fn approval_builds_correct_calldata() {
1912 use wiremock::{
1913 matchers::{method, path},
1914 Mock, MockServer, ResponseTemplate,
1915 };
1916
1917 let server = MockServer::start().await;
1918
1919 Mock::given(method("GET"))
1920 .and(path("/v1/info"))
1921 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
1922 .expect(1)
1923 .mount(&server)
1924 .await;
1925
1926 let sender = Address::with_last_byte(0xab);
1927 let (client, asserter) =
1928 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
1929
1930 let hints = SigningHints {
1932 sender: Some(sender),
1933 nonce: Some(3),
1934 max_fee_per_gas: Some(2_000_000_000),
1935 max_priority_fee_per_gas: Some(1_000_000),
1936 gas_limit: None, simulate: false,
1938 };
1939 asserter.push_success(&65_000u64);
1941
1942 let params = ApprovalParams::new(
1943 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
1944 num_bigint::BigUint::from(1_000_000u64),
1945 false,
1946 );
1947
1948 let payload = client
1949 .approval(¶ms, &hints)
1950 .await
1951 .expect("approval should succeed")
1952 .expect("should build payload when check_allowance is false");
1953
1954 let selector = &payload.tx().input[0..4];
1956 assert_eq!(selector, &[0x09, 0x5e, 0xa7, 0xb3]);
1957 assert_eq!(payload.tx().gas_limit, 65_000, "gas limit should come from eth_estimateGas");
1958 assert_eq!(payload.tx().nonce, 3);
1959 }
1960
1961 #[tokio::test]
1962 async fn approval_with_insufficient_allowance_returns_some() {
1963 use wiremock::{
1964 matchers::{method, path},
1965 Mock, MockServer, ResponseTemplate,
1966 };
1967
1968 let server = MockServer::start().await;
1969
1970 Mock::given(method("GET"))
1971 .and(path("/v1/info"))
1972 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
1973 .expect(1)
1974 .mount(&server)
1975 .await;
1976
1977 let sender = Address::with_last_byte(0xab);
1978 let (client, asserter) =
1979 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
1980
1981 let hints = SigningHints {
1982 sender: Some(sender),
1983 nonce: Some(0),
1984 max_fee_per_gas: Some(1_000_000_000),
1985 max_priority_fee_per_gas: Some(1_000_000),
1986 gas_limit: None,
1987 simulate: false,
1988 };
1989
1990 let zero_allowance = alloy::primitives::Bytes::copy_from_slice(&[0u8; 32]);
1992 asserter.push_success(&zero_allowance);
1993 asserter.push_success(&65_000u64);
1995
1996 let params = ApprovalParams::new(
1997 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
1998 num_bigint::BigUint::from(500_000u64),
1999 true,
2000 );
2001
2002 let result = client
2003 .approval(¶ms, &hints)
2004 .await
2005 .expect("approval with allowance check should succeed");
2006
2007 assert!(result.is_some(), "zero allowance should return a payload");
2008 }
2009
2010 #[tokio::test]
2011 async fn approval_with_sufficient_allowance_returns_none() {
2012 use wiremock::{
2013 matchers::{method, path},
2014 Mock, MockServer, ResponseTemplate,
2015 };
2016
2017 let server = MockServer::start().await;
2018
2019 Mock::given(method("GET"))
2020 .and(path("/v1/info"))
2021 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2022 .expect(1)
2023 .mount(&server)
2024 .await;
2025
2026 let sender = Address::with_last_byte(0xab);
2027 let (client, asserter) =
2028 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2029
2030 let hints = SigningHints {
2031 sender: Some(sender),
2032 nonce: Some(0),
2033 max_fee_per_gas: Some(1_000_000_000),
2034 max_priority_fee_per_gas: Some(1_000_000),
2035 gas_limit: None,
2036 simulate: false,
2037 };
2038
2039 let mut allowance_bytes = [0u8; 32];
2041 allowance_bytes[24..32].copy_from_slice(&1_000_000u64.to_be_bytes());
2043 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&allowance_bytes));
2044
2045 let params = ApprovalParams::new(
2047 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2048 num_bigint::BigUint::from(500_000u64),
2049 true,
2050 );
2051
2052 let result = client
2053 .approval(¶ms, &hints)
2054 .await
2055 .expect("approval with sufficient allowance check should succeed");
2056
2057 assert!(result.is_none(), "sufficient allowance should return None");
2058 }
2059
2060 fn make_signed_approval() -> crate::signing::SignedApproval {
2065 use alloy::primitives::{Signature, TxKind, U256};
2066
2067 use crate::signing::ApprovalPayload;
2068
2069 let tx = TxEip1559 {
2070 chain_id: 1,
2071 nonce: 0,
2072 max_fee_per_gas: 1_000_000_000,
2073 max_priority_fee_per_gas: 1_000_000,
2074 gas_limit: 65_000,
2075 to: TxKind::Call(Address::ZERO),
2076 value: U256::ZERO,
2077 input: AlloyBytes::from(vec![0x09, 0x5e, 0xa7, 0xb3]),
2078 access_list: AccessList::default(),
2079 };
2080 let payload = ApprovalPayload {
2081 tx,
2082 token: bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2083 spender: bytes::Bytes::copy_from_slice(&[0x01; 20]),
2084 amount: num_bigint::BigUint::from(1_000_000u64),
2085 };
2086 SignedApproval::assemble(payload, Signature::test_signature())
2087 }
2088
2089 #[tokio::test]
2090 async fn execute_approval_broadcasts_and_polls() {
2091 let sender = Address::with_last_byte(0xab);
2092 let (client, asserter) =
2093 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
2094
2095 let tx_hash = alloy::primitives::B256::repeat_byte(0xef);
2097 asserter.push_success(&tx_hash);
2098
2099 asserter.push_success::<Option<()>>(&None);
2101 let receipt = alloy::rpc::types::TransactionReceipt {
2102 inner: alloy::consensus::ReceiptEnvelope::Eip1559(alloy::consensus::ReceiptWithBloom {
2103 receipt: alloy::consensus::Receipt::<alloy::primitives::Log> {
2104 status: alloy::consensus::Eip658Value::Eip658(true),
2105 cumulative_gas_used: 50_000,
2106 logs: vec![],
2107 },
2108 logs_bloom: alloy::primitives::Bloom::default(),
2109 }),
2110 transaction_hash: tx_hash,
2111 transaction_index: None,
2112 block_hash: None,
2113 block_number: None,
2114 gas_used: 45_000,
2115 effective_gas_price: 1_500_000_000,
2116 blob_gas_used: None,
2117 blob_gas_price: None,
2118 from: Address::ZERO,
2119 to: None,
2120 contract_address: None,
2121 };
2122 asserter.push_success(&receipt);
2123
2124 let approval = make_signed_approval();
2125 let tx_receipt = client
2126 .execute_approval(approval)
2127 .await
2128 .expect("execute_approval should succeed");
2129
2130 let mined = tx_receipt
2131 .await
2132 .expect("receipt should resolve");
2133
2134 assert_eq!(mined.tx_hash(), tx_hash);
2135 let expected_cost =
2136 num_bigint::BigUint::from(45_000u64) * num_bigint::BigUint::from(1_500_000_000u64);
2137 assert_eq!(mined.gas_cost(), &expected_cost);
2138 }
2139}