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}
201
202impl StorageOverrides {
203 pub fn insert(&mut self, address: Bytes, slot: Bytes, value: Bytes) {
209 self.slots
210 .entry(address)
211 .or_default()
212 .insert(slot, value);
213 }
214
215 pub fn merge(&mut self, other: StorageOverrides) {
217 for (address, slots) in other.slots {
218 let entry = self.slots.entry(address).or_default();
219 entry.extend(slots);
220 }
221 }
222}
223
224fn storage_overrides_to_alloy(so: &StorageOverrides) -> Result<StateOverride, FyndError> {
225 let mut result = StateOverride::default();
226 for (addr_bytes, slot_map) in &so.slots {
227 let addr = mapping::bytes_to_alloy_address(addr_bytes)?;
228 let state_diff = slot_map
229 .iter()
230 .map(|(slot, val)| Ok((bytes_to_b256(slot)?, bytes_to_b256(val)?)))
231 .collect::<Result<alloy::primitives::map::B256HashMap<B256>, FyndError>>()?;
232 result.insert(addr, AccountOverride { state_diff: Some(state_diff), ..Default::default() });
233 }
234 Ok(result)
235}
236
237fn bytes_to_b256(b: &Bytes) -> Result<B256, FyndError> {
238 if b.len() != 32 {
239 return Err(FyndError::Protocol(format!("expected 32-byte slot, got {} bytes", b.len())));
240 }
241 let arr: [u8; 32] = b
242 .as_ref()
243 .try_into()
244 .expect("length checked above");
245 Ok(B256::from(arr))
246}
247
248#[derive(Clone)]
254pub struct ExecutionOptions {
255 pub dry_run: bool,
260 pub storage_overrides: Option<StorageOverrides>,
263 pub fetch_revert_reason: bool,
268}
269
270impl Default for ExecutionOptions {
271 fn default() -> Self {
272 Self { dry_run: false, storage_overrides: None, fetch_revert_reason: true }
273 }
274}
275
276#[derive(Clone)]
283pub enum AllowanceCheck {
284 Skip,
286 AtLeast(BigUint),
292}
293
294#[derive(Clone)]
296pub struct ApprovalParams {
297 token: bytes::Bytes,
298 amount: BigUint,
299 allowance_check: AllowanceCheck,
300 transfer_type: UserTransferType,
301}
302
303impl ApprovalParams {
304 pub fn new(
310 token: bytes::Bytes,
311 amount: num_bigint::BigUint,
312 allowance_check: AllowanceCheck,
313 ) -> Self {
314 Self { token, amount, allowance_check, transfer_type: UserTransferType::TransferFrom }
315 }
316
317 pub fn with_transfer_type(mut self, transfer_type: UserTransferType) -> Self {
323 self.transfer_type = transfer_type;
324 self
325 }
326}
327
328mod erc20 {
333 use alloy::sol;
334
335 sol! {
336 function approve(address spender, uint256 amount) returns (bool);
337 function allowance(address owner, address spender) returns (uint256);
338 }
339}
340
341pub struct FyndClientBuilder {
353 base_url: String,
354 timeout: Duration,
355 retry: RetryConfig,
356 rpc_url: String,
357 submit_url: Option<String>,
358 sender: Option<Address>,
359}
360
361impl FyndClientBuilder {
362 pub fn new(base_url: impl Into<String>, rpc_url: impl Into<String>) -> Self {
368 Self {
369 base_url: base_url.into(),
370 timeout: Duration::from_secs(30),
371 retry: RetryConfig::default(),
372 rpc_url: rpc_url.into(),
373 submit_url: None,
374 sender: None,
375 }
376 }
377
378 pub fn with_timeout(mut self, timeout: Duration) -> Self {
380 self.timeout = timeout;
381 self
382 }
383
384 pub fn with_retry(mut self, retry: RetryConfig) -> Self {
386 self.retry = retry;
387 self
388 }
389
390 pub fn with_submit_url(mut self, url: impl Into<String>) -> Self {
394 self.submit_url = Some(url.into());
395 self
396 }
397
398 pub fn with_sender(mut self, sender: Address) -> Self {
400 self.sender = Some(sender);
401 self
402 }
403
404 pub fn build_quote_only(self) -> Result<FyndClient, FyndError> {
412 let parsed_base = self
413 .base_url
414 .parse::<reqwest::Url>()
415 .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
416 let scheme = parsed_base.scheme();
417 if scheme != "http" && scheme != "https" {
418 return Err(FyndError::Config(format!(
419 "base URL must use http or https scheme, got '{scheme}'"
420 )));
421 }
422
423 let provider = ProviderBuilder::default().connect_http(parsed_base.clone());
426 let submit_provider = ProviderBuilder::default().connect_http(parsed_base);
427
428 let http = HttpClient::builder()
429 .timeout(self.timeout)
430 .build()
431 .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
432
433 Ok(FyndClient {
434 http,
435 base_url: self.base_url,
436 retry: self.retry,
437 chain_id: 1,
438 default_sender: self.sender,
439 provider,
440 submit_provider,
441 info_cache: tokio::sync::OnceCell::new(),
442 })
443 }
444
445 pub async fn build(self) -> Result<FyndClient, FyndError> {
450 let parsed_base = self
452 .base_url
453 .parse::<reqwest::Url>()
454 .map_err(|e| FyndError::Config(format!("invalid base URL: {e}")))?;
455 let scheme = parsed_base.scheme();
456 if scheme != "http" && scheme != "https" {
457 return Err(FyndError::Config(format!(
458 "base URL must use http or https scheme, got '{scheme}'"
459 )));
460 }
461
462 let rpc_url = self
464 .rpc_url
465 .parse::<reqwest::Url>()
466 .map_err(|e| FyndError::Config(format!("invalid RPC URL: {e}")))?;
467 let provider = ProviderBuilder::default().connect_http(rpc_url);
468
469 let submit_url_str = self
470 .submit_url
471 .as_deref()
472 .unwrap_or(&self.rpc_url);
473 let submit_url = submit_url_str
474 .parse::<reqwest::Url>()
475 .map_err(|e| FyndError::Config(format!("invalid submit URL: {e}")))?;
476 let submit_provider = ProviderBuilder::default().connect_http(submit_url);
477
478 let chain_id = provider
480 .get_chain_id()
481 .await
482 .map_err(|e| FyndError::Config(format!("failed to fetch chain_id from RPC: {e}")))?;
483
484 let http = HttpClient::builder()
486 .timeout(self.timeout)
487 .build()
488 .map_err(|e| FyndError::Config(format!("failed to build HTTP client: {e}")))?;
489
490 Ok(FyndClient {
491 http,
492 base_url: self.base_url,
493 retry: self.retry,
494 chain_id,
495 default_sender: self.sender,
496 provider,
497 submit_provider,
498 info_cache: tokio::sync::OnceCell::new(),
499 })
500 }
501}
502
503pub struct FyndClient<P = RootProvider<Ethereum>>
514where
515 P: Provider<Ethereum> + Clone + Send + Sync + 'static,
516{
517 http: HttpClient,
518 base_url: String,
519 retry: RetryConfig,
520 chain_id: u64,
521 default_sender: Option<Address>,
522 provider: P,
523 submit_provider: P,
524 info_cache: tokio::sync::OnceCell<InstanceInfo>,
525}
526
527impl<P> FyndClient<P>
528where
529 P: Provider<Ethereum> + Clone + Send + Sync + 'static,
530{
531 #[doc(hidden)]
535 #[allow(clippy::too_many_arguments)]
536 pub fn new_with_providers(
537 http: HttpClient,
538 base_url: String,
539 retry: RetryConfig,
540 chain_id: u64,
541 default_sender: Option<Address>,
542 provider: P,
543 submit_provider: P,
544 ) -> Self {
545 Self {
546 http,
547 base_url,
548 retry,
549 chain_id,
550 default_sender,
551 provider,
552 submit_provider,
553 info_cache: tokio::sync::OnceCell::new(),
554 }
555 }
556
557 pub async fn quote(&self, params: QuoteParams) -> Result<Quote, FyndError> {
564 let token_out = params.order.token_out().clone();
565 let receiver = params
566 .order
567 .receiver()
568 .unwrap_or_else(|| params.order.sender())
569 .clone();
570 let dto_request = mapping::quote_params_to_dto(params)?;
571
572 let mut delay = self.retry.initial_backoff;
573 for attempt in 0..self.retry.max_attempts {
574 match self
575 .request_quote(&dto_request, token_out.clone(), receiver.clone())
576 .await
577 {
578 Ok(quote) => return Ok(quote),
579 Err(e) if e.is_retryable() && attempt + 1 < self.retry.max_attempts => {
580 tracing::debug!(attempt, "quote request failed, retrying");
581 tokio::time::sleep(delay).await;
582 delay = (delay * 2).min(self.retry.max_backoff);
583 }
584 Err(e) => return Err(e),
585 }
586 }
587 Err(FyndError::Protocol("retry loop exhausted without result".into()))
588 }
589
590 async fn request_quote(
591 &self,
592 dto_request: &fynd_rpc_types::QuoteRequest,
593 token_out: Bytes,
594 receiver: Bytes,
595 ) -> Result<Quote, FyndError> {
596 let url = format!("{}/v1/quote", self.base_url);
597 let response = self
598 .http
599 .post(&url)
600 .json(dto_request)
601 .send()
602 .await?;
603 if !response.status().is_success() {
604 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
605 return Err(mapping::dto_error_to_fynd(dto_err));
606 }
607 let dto_quote: fynd_rpc_types::Quote = response.json().await?;
608 mapping::map_quote_response(dto_quote, vec![(token_out, receiver)])?
609 .into_iter()
610 .next()
611 .ok_or_else(|| FyndError::Protocol("server returned empty quote list".into()))
612 }
613
614 pub async fn batch_quote(&self, params: BatchQuoteParams) -> Result<Vec<Quote>, FyndError> {
622 let (dto_request, order_meta) = mapping::batch_quote_params_to_dto(params)?;
623
624 let mut delay = self.retry.initial_backoff;
625 for attempt in 0..self.retry.max_attempts {
626 match self
627 .request_batch_quote(&dto_request, order_meta.clone())
628 .await
629 {
630 Ok(quotes) => return Ok(quotes),
631 Err(e) if e.is_retryable() && attempt + 1 < self.retry.max_attempts => {
632 tracing::debug!(attempt, "batch_quote request failed, retrying");
633 tokio::time::sleep(delay).await;
634 delay = (delay * 2).min(self.retry.max_backoff);
635 }
636 Err(e) => return Err(e),
637 }
638 }
639 Err(FyndError::Protocol("retry loop exhausted without result".into()))
640 }
641
642 async fn request_batch_quote(
643 &self,
644 dto_request: &fynd_rpc_types::QuoteRequest,
645 order_meta: Vec<(Bytes, Bytes)>,
646 ) -> Result<Vec<Quote>, FyndError> {
647 let url = format!("{}/v1/quote", self.base_url);
648 let response = self
649 .http
650 .post(&url)
651 .json(dto_request)
652 .send()
653 .await?;
654 if !response.status().is_success() {
655 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
656 return Err(mapping::dto_error_to_fynd(dto_err));
657 }
658 let dto_quote: fynd_rpc_types::Quote = response.json().await?;
659 mapping::map_quote_response(dto_quote, order_meta)
660 }
661
662 pub async fn health(&self) -> Result<HealthStatus, FyndError> {
664 let url = format!("{}/v1/health", self.base_url);
665 let response = self.http.get(&url).send().await?;
666 let status = response.status();
667 let body = response.text().await?;
668 if let Ok(dh) = serde_json::from_str::<fynd_rpc_types::HealthStatus>(&body) {
671 return Ok(HealthStatus::from(dh));
672 }
673 if let Ok(dto_err) = serde_json::from_str::<fynd_rpc_types::ErrorResponse>(&body) {
674 return Err(mapping::dto_error_to_fynd(dto_err));
675 }
676 Err(FyndError::Protocol(format!("unexpected health response ({status}): {body}")))
677 }
678
679 pub async fn swap_payload(
691 &self,
692 quote: Quote,
693 hints: &SigningHints,
694 ) -> Result<SwapPayload, FyndError> {
695 match quote.backend() {
696 BackendKind::Fynd => {
697 self.fynd_swap_payload(quote, hints)
698 .await
699 }
700 BackendKind::Turbine => {
701 Err(FyndError::Protocol("Turbine signing not yet implemented".into()))
702 }
703 }
704 }
705
706 async fn fynd_swap_payload(
707 &self,
708 quote: Quote,
709 hints: &SigningHints,
710 ) -> Result<SwapPayload, FyndError> {
711 let sender = hints
713 .sender()
714 .or(self.default_sender)
715 .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
716
717 let nonce = match hints.nonce() {
719 Some(n) => n,
720 None => self
721 .provider
722 .get_transaction_count(sender)
723 .await
724 .map_err(FyndError::Provider)?,
725 };
726
727 let (max_fee_per_gas, max_priority_fee_per_gas) =
729 match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
730 (Some(mf), Some(mp)) => (mf, mp),
731 (mf, mp) => {
732 let est = self
733 .provider
734 .estimate_eip1559_fees()
735 .await
736 .map_err(FyndError::Provider)?;
737 (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
738 }
739 };
740
741 let tx_data = quote.transaction().ok_or_else(|| {
742 FyndError::Protocol(
743 "quote has no calldata; set encoding_options in QuoteOptions".into(),
744 )
745 })?;
746 let to_addr = mapping::bytes_to_alloy_address(tx_data.to())?;
747 let value = mapping::biguint_to_u256(tx_data.value());
748 let input = AlloyBytes::from(tx_data.data().to_vec());
749
750 let gas_limit = match hints.gas_limit() {
754 Some(g) => g,
755 None => {
756 let req = alloy::rpc::types::TransactionRequest::default()
757 .from(sender)
758 .to(to_addr)
759 .value(value)
760 .input(input.clone().into());
761 self.provider
762 .estimate_gas(req)
763 .await
764 .map_err(FyndError::Provider)?
765 }
766 };
767
768 let tx_eip1559 = TxEip1559 {
769 chain_id: self.chain_id,
770 nonce,
771 max_fee_per_gas,
772 max_priority_fee_per_gas,
773 gas_limit,
774 to: TxKind::Call(to_addr),
775 value,
776 input,
777 access_list: AccessList::default(),
778 };
779
780 if hints.simulate() {
782 let req = alloy::rpc::types::TransactionRequest::from_transaction_with_sender(
783 tx_eip1559.clone(),
784 sender,
785 );
786 self.provider
787 .call(req)
788 .await
789 .map_err(|e| {
790 FyndError::SimulationFailed(format!("transaction simulation failed: {e}"))
791 })?;
792 }
793
794 let tx = TypedTransaction::Eip1559(tx_eip1559);
795 Ok(SwapPayload::Fynd(Box::new(FyndPayload::new(quote, tx))))
796 }
797
798 pub async fn execute_swap(
809 &self,
810 order: SignedSwap,
811 options: &ExecutionOptions,
812 ) -> Result<ExecutionReceipt, FyndError> {
813 let (payload, signature) = order.into_parts();
814 let (quote, tx) = payload.into_fynd_parts()?;
815
816 let TypedTransaction::Eip1559(tx_eip1559) = tx else {
817 return Err(FyndError::Protocol(
818 "only EIP-1559 transactions are supported for execution".into(),
819 ));
820 };
821
822 if options.dry_run {
823 return self
824 .dry_run_execute(tx_eip1559, options)
825 .await;
826 }
827
828 let tx_hash = self
829 .send_raw(tx_eip1559.clone(), signature)
830 .await?;
831
832 let token_out_addr = mapping::bytes_to_alloy_address(quote.token_out())?;
833 let receiver_addr = mapping::bytes_to_alloy_address(quote.receiver())?;
834 let provider = self.submit_provider.clone();
835 let fetch_revert = options.fetch_revert_reason;
836 let fallback_to = match tx_eip1559.to {
840 TxKind::Call(addr) => addr,
841 TxKind::Create => Address::ZERO,
842 };
843 let fallback_req = TransactionRequest::default()
844 .to(fallback_to)
845 .value(tx_eip1559.value)
846 .input(tx_eip1559.input.clone().into());
847
848 Ok(ExecutionReceipt::Transaction(Box::pin(async move {
849 loop {
850 match provider
851 .get_transaction_receipt(tx_hash)
852 .await
853 .map_err(FyndError::Provider)?
854 {
855 Some(receipt) => {
856 if !receipt.status() {
857 let reason = if fetch_revert {
858 let trace: Result<serde_json::Value, _> = provider
860 .raw_request(
861 std::borrow::Cow::Borrowed("debug_traceTransaction"),
862 (tx_hash, serde_json::json!({})),
863 )
864 .await;
865 match trace {
866 Ok(t) => {
867 let hex_str = t
868 .get("returnValue")
869 .and_then(|v| v.as_str())
870 .unwrap_or("");
871 match alloy::primitives::hex::decode(
872 hex_str.trim_start_matches("0x"),
873 ) {
874 Ok(b) => decode_revert_bytes(&b),
875 Err(_) => format!(
876 "{tx_hash:#x} reverted (return value: {hex_str})"
877 ),
878 }
879 }
880 Err(_) => {
881 tracing::warn!(
882 tx = ?tx_hash,
883 "debug_traceTransaction unavailable; replaying via \
884 eth_call — block state may differ"
885 );
886 match provider.call(fallback_req).await {
887 Err(e) => e.to_string(),
888 Ok(_) => {
889 format!("{tx_hash:#x} reverted (no reason)")
890 }
891 }
892 }
893 }
894 } else {
895 format!("{tx_hash:#x}")
896 };
897 return Err(FyndError::TransactionReverted(reason));
898 }
899 let settled_amount =
900 compute_settled_amount(&receipt, &token_out_addr, &receiver_addr);
901 let gas_cost = BigUint::from(receipt.gas_used) *
902 BigUint::from(receipt.effective_gas_price);
903 return Ok(SettledOrder::new(Some(tx_hash), settled_amount, gas_cost));
904 }
905 None => tokio::time::sleep(Duration::from_secs(2)).await,
906 }
907 }
908 })))
909 }
910
911 pub async fn info(&self) -> Result<&InstanceInfo, FyndError> {
916 self.info_cache
917 .get_or_try_init(|| self.fetch_info())
918 .await
919 }
920
921 async fn fetch_info(&self) -> Result<InstanceInfo, FyndError> {
922 let url = format!("{}/v1/info", self.base_url);
923 let response = self.http.get(&url).send().await?;
924 if !response.status().is_success() {
925 let dto_err: fynd_rpc_types::ErrorResponse = response.json().await?;
926 return Err(mapping::dto_error_to_fynd(dto_err));
927 }
928 let dto_info: fynd_rpc_types::InstanceInfo = response.json().await?;
929 dto_info.try_into()
930 }
931
932 pub async fn approval(
946 &self,
947 params: &ApprovalParams,
948 hints: &SigningHints,
949 ) -> Result<Option<ApprovalPayload>, FyndError> {
950 use alloy::sol_types::SolCall;
951
952 let info = self.info().await?;
953 let spender_addr = match params.transfer_type {
954 UserTransferType::TransferFrom => {
955 mapping::bytes_to_alloy_address(info.router_address())?
956 }
957 UserTransferType::TransferFromPermit2 => {
958 mapping::bytes_to_alloy_address(info.permit2_address())?
959 }
960 UserTransferType::UseVaultsFunds => return Ok(None),
961 };
962
963 let sender = hints
964 .sender()
965 .or(self.default_sender)
966 .ok_or_else(|| FyndError::Config("no sender configured".into()))?;
967
968 let token_addr = mapping::bytes_to_alloy_address(¶ms.token)?;
969 let amount_u256 = mapping::biguint_to_u256(¶ms.amount);
970
971 if let AllowanceCheck::AtLeast(min) = ¶ms.allowance_check {
973 let call_data =
974 erc20::allowanceCall { owner: sender, spender: spender_addr }.abi_encode();
975 let req = alloy::rpc::types::TransactionRequest {
976 to: Some(alloy::primitives::TxKind::Call(token_addr)),
977 input: alloy::rpc::types::TransactionInput::new(AlloyBytes::from(call_data)),
978 ..Default::default()
979 };
980 let result = self
981 .provider
982 .call(req)
983 .await
984 .map_err(|e| FyndError::Protocol(format!("allowance call failed: {e}")))?;
985 let current_allowance = if result.len() >= 32 {
986 alloy::primitives::U256::from_be_slice(&result[0..32])
987 } else {
988 alloy::primitives::U256::ZERO
989 };
990 if current_allowance >= mapping::biguint_to_u256(min) {
991 return Ok(None);
992 }
993 }
994
995 let nonce = match hints.nonce() {
997 Some(n) => n,
998 None => self
999 .provider
1000 .get_transaction_count(sender)
1001 .await
1002 .map_err(FyndError::Provider)?,
1003 };
1004
1005 let (max_fee_per_gas, max_priority_fee_per_gas) =
1007 match (hints.max_fee_per_gas(), hints.max_priority_fee_per_gas()) {
1008 (Some(mf), Some(mp)) => (mf, mp),
1009 (mf, mp) => {
1010 let est = self
1011 .provider
1012 .estimate_eip1559_fees()
1013 .await
1014 .map_err(FyndError::Provider)?;
1015 (mf.unwrap_or(est.max_fee_per_gas), mp.unwrap_or(est.max_priority_fee_per_gas))
1016 }
1017 };
1018
1019 let calldata =
1020 erc20::approveCall { spender: spender_addr, amount: amount_u256 }.abi_encode();
1021
1022 let gas_limit = match hints.gas_limit() {
1024 Some(g) => g,
1025 None => {
1026 let req = alloy::rpc::types::TransactionRequest::default()
1027 .from(sender)
1028 .to(token_addr)
1029 .input(AlloyBytes::from(calldata.clone()).into());
1030 self.provider
1031 .estimate_gas(req)
1032 .await
1033 .map_err(FyndError::Provider)?
1034 }
1035 };
1036
1037 let tx = TxEip1559 {
1038 chain_id: self.chain_id,
1039 nonce,
1040 max_fee_per_gas,
1041 max_priority_fee_per_gas,
1042 gas_limit,
1043 to: alloy::primitives::TxKind::Call(token_addr),
1044 value: alloy::primitives::U256::ZERO,
1045 input: AlloyBytes::from(calldata),
1046 access_list: alloy::eips::eip2930::AccessList::default(),
1047 };
1048
1049 let spender = bytes::Bytes::copy_from_slice(spender_addr.as_slice());
1050 Ok(Some(ApprovalPayload {
1051 tx,
1052 token: params.token.clone(),
1053 spender,
1054 amount: params.amount.clone(),
1055 }))
1056 }
1057
1058 pub async fn execute_approval(&self, approval: SignedApproval) -> Result<TxReceipt, FyndError> {
1064 let (payload, signature) = approval.into_parts();
1065 let fallback_req = TransactionRequest::default()
1066 .to(mapping::bytes_to_alloy_address(&payload.token)?)
1067 .input(payload.tx.input.clone().into());
1068 let tx_hash = self
1069 .send_raw(payload.tx, signature)
1070 .await?;
1071 let provider = self.submit_provider.clone();
1072
1073 Ok(TxReceipt::Pending(Box::pin(async move {
1074 loop {
1075 match provider
1076 .get_transaction_receipt(tx_hash)
1077 .await
1078 .map_err(FyndError::Provider)?
1079 {
1080 Some(receipt) => {
1081 if !receipt.status() {
1082 let trace: Result<serde_json::Value, _> = provider
1083 .raw_request(
1084 std::borrow::Cow::Borrowed("debug_traceTransaction"),
1085 (tx_hash, serde_json::json!({})),
1086 )
1087 .await;
1088 let reason = match trace {
1089 Ok(t) => {
1090 let hex_str = t
1091 .get("returnValue")
1092 .and_then(|v| v.as_str())
1093 .unwrap_or("");
1094 match alloy::primitives::hex::decode(
1095 hex_str.trim_start_matches("0x"),
1096 ) {
1097 Ok(b) => decode_revert_bytes(&b),
1098 Err(_) => format!(
1099 "{tx_hash:#x} reverted (return value: {hex_str})"
1100 ),
1101 }
1102 }
1103 Err(_) => {
1104 tracing::warn!(
1105 tx = ?tx_hash,
1106 "debug_traceTransaction unavailable; replaying via \
1107 eth_call — block state may differ"
1108 );
1109 match provider.call(fallback_req).await {
1110 Err(e) => e.to_string(),
1111 Ok(_) => format!("{tx_hash:#x} reverted (no reason)"),
1112 }
1113 }
1114 };
1115 return Err(FyndError::TransactionReverted(reason));
1116 }
1117 let gas_cost = BigUint::from(receipt.gas_used) *
1118 BigUint::from(receipt.effective_gas_price);
1119 return Ok(MinedTx::new(tx_hash, gas_cost));
1120 }
1121 None => tokio::time::sleep(Duration::from_secs(2)).await,
1122 }
1123 }
1124 })))
1125 }
1126
1127 async fn send_raw(
1129 &self,
1130 tx: TxEip1559,
1131 signature: alloy::primitives::Signature,
1132 ) -> Result<B256, FyndError> {
1133 use alloy::eips::eip2718::Encodable2718;
1134 let envelope = TypedTransaction::Eip1559(tx).into_envelope(signature);
1135 let raw = envelope.encoded_2718();
1136 let pending = self
1137 .submit_provider
1138 .send_raw_transaction(&raw)
1139 .await
1140 .map_err(FyndError::Provider)?;
1141 Ok(*pending.tx_hash())
1142 }
1143
1144 async fn dry_run_execute(
1145 &self,
1146 tx_eip1559: TxEip1559,
1147 options: &ExecutionOptions,
1148 ) -> Result<ExecutionReceipt, FyndError> {
1149 let mut req: TransactionRequest = tx_eip1559.clone().into();
1150 if let Some(sender) = self.default_sender {
1151 req.from = Some(sender);
1152 }
1153 let overrides = options
1154 .storage_overrides
1155 .as_ref()
1156 .map(storage_overrides_to_alloy)
1157 .transpose()?;
1158
1159 let return_data = self
1160 .provider
1161 .call(req.clone())
1162 .overrides_opt(overrides.clone())
1163 .await
1164 .map_err(|e| FyndError::SimulationFailed(format!("dry run simulation failed: {e}")))?;
1165
1166 let gas_used = self
1167 .provider
1168 .estimate_gas(req)
1169 .overrides_opt(overrides)
1170 .await
1171 .map_err(|e| {
1172 FyndError::SimulationFailed(format!("dry run gas estimation failed: {e}"))
1173 })?;
1174
1175 let settled_amount = if return_data.len() >= 32 {
1176 Some(BigUint::from_bytes_be(&return_data[0..32]))
1177 } else {
1178 None
1179 };
1180 let gas_cost = BigUint::from(gas_used) * BigUint::from(tx_eip1559.max_fee_per_gas);
1181 let settled = SettledOrder::new(None, settled_amount, gas_cost);
1182
1183 Ok(ExecutionReceipt::Transaction(Box::pin(async move { Ok(settled) })))
1184 }
1185}
1186
1187fn decode_revert_bytes(data: &[u8]) -> String {
1192 const SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0];
1194 if data.len() >= 68 && data[..4] == SELECTOR {
1195 let str_len = u64::from_be_bytes(
1196 data[60..68]
1197 .try_into()
1198 .unwrap_or([0u8; 8]),
1199 ) as usize;
1200 if data.len() >= 68 + str_len {
1201 if let Ok(s) = std::str::from_utf8(&data[68..68 + str_len]) {
1202 return s.to_owned();
1203 }
1204 }
1205 }
1206 if data.is_empty() {
1207 "empty revert data".to_owned()
1208 } else {
1209 format!("0x{}", alloy::primitives::hex::encode(data))
1210 }
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215 use std::time::Duration;
1216
1217 use super::*;
1218
1219 #[test]
1220 fn retry_config_default_values() {
1221 let config = RetryConfig::default();
1222 assert_eq!(config.max_attempts(), 3);
1223 assert_eq!(config.initial_backoff(), Duration::from_millis(100));
1224 assert_eq!(config.max_backoff(), Duration::from_secs(2));
1225 }
1226
1227 #[test]
1228 fn signing_hints_default_all_none_and_no_simulate() {
1229 let hints = SigningHints::default();
1230 assert!(hints.sender().is_none());
1231 assert!(hints.nonce().is_none());
1232 assert!(!hints.simulate());
1233 }
1234
1235 fn make_test_client(
1244 base_url: String,
1245 retry: RetryConfig,
1246 default_sender: Option<Address>,
1247 ) -> (FyndClient<alloy::providers::RootProvider<Ethereum>>, alloy::providers::mock::Asserter)
1248 {
1249 use alloy::providers::{mock::Asserter, ProviderBuilder};
1250
1251 let asserter = Asserter::new();
1252 let provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1253 let submit_provider = ProviderBuilder::default().connect_mocked_client(asserter.clone());
1254
1255 let http = HttpClient::builder()
1256 .timeout(Duration::from_secs(5))
1257 .build()
1258 .expect("reqwest client");
1259
1260 let client = FyndClient::new_with_providers(
1261 http,
1262 base_url,
1263 retry,
1264 1,
1265 default_sender,
1266 provider,
1267 submit_provider,
1268 );
1269
1270 (client, asserter)
1271 }
1272
1273 fn make_order_quote() -> crate::types::Quote {
1275 use num_bigint::BigUint;
1276
1277 use crate::types::{BackendKind, BlockInfo, QuoteStatus, Transaction};
1278
1279 let tx = Transaction::new(
1280 bytes::Bytes::copy_from_slice(&[0x01; 20]),
1281 BigUint::ZERO,
1282 vec![0x12, 0x34],
1283 );
1284
1285 crate::types::Quote::new(
1286 "test-order-id".to_string(),
1287 QuoteStatus::Success,
1288 BackendKind::Fynd,
1289 None,
1290 BigUint::from(1_000_000u64),
1291 BigUint::from(990_000u64),
1292 BigUint::from(50_000u64),
1293 BigUint::from(940_000u64),
1294 Some(10),
1295 BlockInfo::new(1_234_567, "0xabcdef".to_string(), 1_700_000_000),
1296 bytes::Bytes::copy_from_slice(&[0xbb; 20]),
1297 bytes::Bytes::copy_from_slice(&[0xcc; 20]),
1298 Some(tx),
1299 None,
1300 )
1301 }
1302
1303 #[tokio::test]
1308 async fn quote_returns_parsed_quote_on_success() {
1309 use wiremock::{
1310 matchers::{method, path},
1311 Mock, MockServer, ResponseTemplate,
1312 };
1313
1314 let server = MockServer::start().await;
1315 let body = serde_json::json!({
1316 "orders": [{
1317 "order_id": "abc-123",
1318 "status": "success",
1319 "amount_in": "1000000",
1320 "amount_out": "990000",
1321 "gas_estimate": "50000",
1322 "amount_out_net_gas": "940000",
1323 "price_impact_bps": 10,
1324 "block": {
1325 "number": 1234567,
1326 "hash": "0xabcdef",
1327 "timestamp": 1700000000
1328 }
1329 }],
1330 "total_gas_estimate": "50000",
1331 "solve_time_ms": 42
1332 });
1333
1334 Mock::given(method("POST"))
1335 .and(path("/v1/quote"))
1336 .respond_with(ResponseTemplate::new(200).set_body_json(body))
1337 .expect(1)
1338 .mount(&server)
1339 .await;
1340
1341 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1342
1343 let params = make_quote_params();
1344 let quote = client
1345 .quote(params)
1346 .await
1347 .expect("quote should succeed");
1348
1349 assert_eq!(quote.order_id(), "abc-123");
1350 assert_eq!(quote.amount_out(), &num_bigint::BigUint::from(990_000u64));
1351 }
1352
1353 #[tokio::test]
1354 async fn quote_returns_api_error_on_non_retryable_server_error() {
1355 use wiremock::{
1356 matchers::{method, path},
1357 Mock, MockServer, ResponseTemplate,
1358 };
1359
1360 use crate::error::ErrorCode;
1361
1362 let server = MockServer::start().await;
1363
1364 Mock::given(method("POST"))
1365 .and(path("/v1/quote"))
1366 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
1367 "error": "bad input",
1368 "code": "BAD_REQUEST"
1369 })))
1370 .expect(1)
1371 .mount(&server)
1372 .await;
1373
1374 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1375
1376 let err = client
1377 .quote(make_quote_params())
1378 .await
1379 .unwrap_err();
1380 assert!(
1381 matches!(err, FyndError::Api { code: ErrorCode::BadRequest, .. }),
1382 "expected BadRequest, got {err:?}"
1383 );
1384 }
1385
1386 #[tokio::test]
1387 async fn quote_retries_on_retryable_error_then_succeeds() {
1388 use wiremock::{
1389 matchers::{method, path},
1390 Mock, MockServer, ResponseTemplate,
1391 };
1392
1393 let server = MockServer::start().await;
1394
1395 Mock::given(method("POST"))
1397 .and(path("/v1/quote"))
1398 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1399 "error": "queue full",
1400 "code": "QUEUE_FULL"
1401 })))
1402 .up_to_n_times(1)
1403 .mount(&server)
1404 .await;
1405
1406 let success_body = serde_json::json!({
1408 "orders": [{
1409 "order_id": "retry-order",
1410 "status": "success",
1411 "amount_in": "1000000",
1412 "amount_out": "990000",
1413 "gas_estimate": "50000",
1414 "amount_out_net_gas": "940000",
1415 "price_impact_bps": null,
1416 "block": {
1417 "number": 1234568,
1418 "hash": "0xabcdef01",
1419 "timestamp": 1700000012
1420 }
1421 }],
1422 "total_gas_estimate": "50000",
1423 "solve_time_ms": 10
1424 });
1425 Mock::given(method("POST"))
1426 .and(path("/v1/quote"))
1427 .respond_with(ResponseTemplate::new(200).set_body_json(success_body))
1428 .up_to_n_times(1)
1429 .mount(&server)
1430 .await;
1431
1432 let retry = RetryConfig::new(3, Duration::from_millis(1), Duration::from_millis(10));
1433 let (client, _asserter) = make_test_client(server.uri(), retry, None);
1434
1435 let quote = client
1436 .quote(make_quote_params())
1437 .await
1438 .expect("should succeed after retry");
1439 assert_eq!(quote.order_id(), "retry-order");
1440 }
1441
1442 #[tokio::test]
1443 async fn quote_exhausts_retries_and_returns_last_error() {
1444 use wiremock::{
1445 matchers::{method, path},
1446 Mock, MockServer, ResponseTemplate,
1447 };
1448
1449 use crate::error::ErrorCode;
1450
1451 let server = MockServer::start().await;
1452
1453 Mock::given(method("POST"))
1454 .and(path("/v1/quote"))
1455 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1456 "error": "queue full",
1457 "code": "QUEUE_FULL"
1458 })))
1459 .mount(&server)
1460 .await;
1461
1462 let retry = RetryConfig::new(2, Duration::from_millis(1), Duration::from_millis(10));
1463 let (client, _asserter) = make_test_client(server.uri(), retry, None);
1464
1465 let err = client
1466 .quote(make_quote_params())
1467 .await
1468 .unwrap_err();
1469 assert!(
1470 matches!(err, FyndError::Api { code: ErrorCode::ServiceUnavailable, .. }),
1471 "expected ServiceUnavailable after retry exhaustion, got {err:?}"
1472 );
1473 }
1474
1475 #[tokio::test]
1476 async fn quote_returns_error_on_malformed_response() {
1477 use wiremock::{
1478 matchers::{method, path},
1479 Mock, MockServer, ResponseTemplate,
1480 };
1481
1482 let server = MockServer::start().await;
1483
1484 Mock::given(method("POST"))
1485 .and(path("/v1/quote"))
1486 .respond_with(
1487 ResponseTemplate::new(200).set_body_json(serde_json::json!({"garbage": true})),
1488 )
1489 .mount(&server)
1490 .await;
1491
1492 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1493
1494 let err = client
1495 .quote(make_quote_params())
1496 .await
1497 .unwrap_err();
1498 assert!(
1500 matches!(err, FyndError::Http(_)),
1501 "expected Http deserialization error, got {err:?}"
1502 );
1503 }
1504
1505 #[tokio::test]
1510 async fn health_returns_status_on_success() {
1511 use wiremock::{
1512 matchers::{method, path},
1513 Mock, MockServer, ResponseTemplate,
1514 };
1515
1516 let server = MockServer::start().await;
1517
1518 Mock::given(method("GET"))
1519 .and(path("/v1/health"))
1520 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1521 "healthy": true,
1522 "last_update_ms": 100,
1523 "num_solver_pools": 5
1524 })))
1525 .expect(1)
1526 .mount(&server)
1527 .await;
1528
1529 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1530
1531 let status = client
1532 .health()
1533 .await
1534 .expect("health should succeed");
1535 assert!(status.healthy());
1536 assert_eq!(status.last_update_ms(), 100);
1537 assert_eq!(status.num_solver_pools(), 5);
1538 }
1539
1540 #[tokio::test]
1541 async fn health_returns_error_on_server_failure() {
1542 use wiremock::{
1543 matchers::{method, path},
1544 Mock, MockServer, ResponseTemplate,
1545 };
1546
1547 let server = MockServer::start().await;
1548
1549 Mock::given(method("GET"))
1550 .and(path("/v1/health"))
1551 .respond_with(ResponseTemplate::new(503).set_body_json(serde_json::json!({
1552 "error": "service unavailable",
1553 "code": "NOT_READY"
1554 })))
1555 .expect(1)
1556 .mount(&server)
1557 .await;
1558
1559 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1560
1561 let err = client.health().await.unwrap_err();
1562 assert!(matches!(err, FyndError::Api { .. }), "expected Api error, got {err:?}");
1563 }
1564
1565 #[tokio::test]
1570 async fn swap_payload_uses_hints_when_all_provided() {
1571 let sender = Address::with_last_byte(0xab);
1572 let (client, _asserter) =
1573 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1574
1575 let quote = make_order_quote();
1576 let hints = SigningHints {
1577 sender: Some(sender),
1578 nonce: Some(5),
1579 max_fee_per_gas: Some(1_000_000_000),
1580 max_priority_fee_per_gas: Some(1_000_000),
1581 gas_limit: Some(100_000),
1582 simulate: false,
1583 };
1584
1585 let payload = client
1586 .swap_payload(quote, &hints)
1587 .await
1588 .expect("swap_payload should succeed");
1589
1590 let SwapPayload::Fynd(fynd) = payload else {
1591 panic!("expected Fynd payload");
1592 };
1593 let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1594 panic!("expected EIP-1559 transaction");
1595 };
1596 assert_eq!(tx.nonce, 5);
1597 assert_eq!(tx.max_fee_per_gas, 1_000_000_000);
1598 assert_eq!(tx.max_priority_fee_per_gas, 1_000_000);
1599 assert_eq!(tx.gas_limit, 100_000);
1600 }
1601
1602 #[tokio::test]
1603 async fn swap_payload_fetches_nonce_and_fees_when_hints_absent() {
1604 let sender = Address::with_last_byte(0xde);
1605 let (client, asserter) =
1606 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1607
1608 asserter.push_success(&7u64);
1610 let fee_history = serde_json::json!({
1614 "oldestBlock": "0x1",
1615 "baseFeePerGas": ["0x3b9aca00", "0x3b9aca00"],
1616 "gasUsedRatio": [0.5],
1617 "reward": [["0xf4240", "0x1e8480"]]
1618 });
1619 asserter.push_success(&fee_history);
1620 asserter.push_success(&150_000u64);
1622
1623 let quote = make_order_quote();
1624 let hints = SigningHints::default();
1625
1626 let payload = client
1627 .swap_payload(quote, &hints)
1628 .await
1629 .expect("swap_payload should succeed");
1630
1631 let SwapPayload::Fynd(fynd) = payload else {
1632 panic!("expected Fynd payload");
1633 };
1634 let TypedTransaction::Eip1559(tx) = fynd.tx() else {
1635 panic!("expected EIP-1559 transaction");
1636 };
1637 assert_eq!(tx.nonce, 7, "nonce should come from mock");
1638 assert_eq!(tx.gas_limit, 150_000, "gas limit should come from eth_estimateGas");
1639 }
1640
1641 #[tokio::test]
1642 async fn swap_payload_returns_config_error_when_no_sender() {
1643 let (client, _asserter) =
1645 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1646
1647 let quote = make_order_quote();
1648 let hints = SigningHints::default(); let err = client
1651 .swap_payload(quote, &hints)
1652 .await
1653 .unwrap_err();
1654
1655 assert!(matches!(err, FyndError::Config(_)), "expected Config error, got {err:?}");
1656 }
1657
1658 #[tokio::test]
1659 async fn swap_payload_with_simulate_true_calls_eth_call_successfully() {
1660 let sender = Address::with_last_byte(0xab);
1661 let (client, asserter) =
1662 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1663
1664 let quote = make_order_quote();
1665 let hints = SigningHints {
1666 sender: Some(sender),
1667 nonce: Some(1),
1668 max_fee_per_gas: Some(1_000_000_000),
1669 max_priority_fee_per_gas: Some(1_000_000),
1670 gas_limit: Some(100_000),
1671 simulate: true,
1672 };
1673
1674 asserter.push_success(&alloy::primitives::Bytes::new());
1676
1677 let payload = client
1678 .swap_payload(quote, &hints)
1679 .await
1680 .expect("swap_payload with simulate=true should succeed");
1681
1682 assert!(matches!(payload, SwapPayload::Fynd(_)));
1683 }
1684
1685 #[tokio::test]
1686 async fn swap_payload_with_simulate_true_returns_simulation_failed_on_revert() {
1687 let sender = Address::with_last_byte(0xab);
1688 let (client, asserter) =
1689 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1690
1691 let quote = make_order_quote();
1692 let hints = SigningHints {
1693 sender: Some(sender),
1694 nonce: Some(1),
1695 max_fee_per_gas: Some(1_000_000_000),
1696 max_priority_fee_per_gas: Some(1_000_000),
1697 gas_limit: Some(100_000),
1698 simulate: true,
1699 };
1700
1701 asserter.push_failure_msg("execution reverted");
1703
1704 let err = client
1705 .swap_payload(quote, &hints)
1706 .await
1707 .unwrap_err();
1708
1709 assert!(
1710 matches!(err, FyndError::SimulationFailed(_)),
1711 "expected SimulationFailed, got {err:?}"
1712 );
1713 }
1714
1715 fn make_signed_swap() -> SignedSwap {
1724 use alloy::{
1725 eips::eip2930::AccessList,
1726 primitives::{Bytes as AlloyBytes, Signature, TxKind, U256},
1727 };
1728
1729 use crate::signing::FyndPayload;
1730
1731 let quote = make_order_quote();
1732 let tx = TxEip1559 {
1733 chain_id: 1,
1734 nonce: 1,
1735 max_fee_per_gas: 1_000_000_000,
1736 max_priority_fee_per_gas: 1_000_000,
1737 gas_limit: 100_000,
1738 to: TxKind::Call(Address::ZERO),
1739 value: U256::ZERO,
1740 input: AlloyBytes::new(),
1741 access_list: AccessList::default(),
1742 };
1743 let payload =
1744 SwapPayload::Fynd(Box::new(FyndPayload::new(quote, TypedTransaction::Eip1559(tx))));
1745 SignedSwap::assemble(payload, Signature::test_signature())
1746 }
1747
1748 #[tokio::test]
1749 async fn execute_dry_run_returns_settled_order_without_broadcast() {
1750 let sender = Address::with_last_byte(0xab);
1751 let (client, asserter) =
1752 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1753
1754 let mut amount_bytes = vec![0u8; 32];
1756 amount_bytes[24..32].copy_from_slice(&990_000u64.to_be_bytes());
1757 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1758 asserter.push_success(&50_000u64); let order = make_signed_swap();
1761 let opts =
1762 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1763 let receipt = client
1764 .execute_swap(order, &opts)
1765 .await
1766 .expect("execute should succeed");
1767 let settled = receipt
1768 .await
1769 .expect("should resolve immediately");
1770
1771 assert_eq!(settled.settled_amount(), Some(&num_bigint::BigUint::from(990_000u64)),);
1772 let expected_gas_cost =
1773 num_bigint::BigUint::from(50_000u64) * num_bigint::BigUint::from(1_000_000_000u64);
1774 assert_eq!(settled.gas_cost(), &expected_gas_cost);
1775 }
1776
1777 #[tokio::test]
1778 async fn execute_dry_run_with_storage_overrides_succeeds() {
1779 let sender = Address::with_last_byte(0xab);
1780 let (client, asserter) =
1781 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1782
1783 let mut overrides = StorageOverrides::default();
1784 overrides.insert(
1785 bytes::Bytes::copy_from_slice(&[0u8; 20]),
1786 bytes::Bytes::copy_from_slice(&[0u8; 32]),
1787 bytes::Bytes::copy_from_slice(&[1u8; 32]),
1788 );
1789
1790 let mut amount_bytes = vec![0u8; 32];
1791 amount_bytes[24..32].copy_from_slice(&100u64.to_be_bytes());
1792 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&amount_bytes));
1793 asserter.push_success(&21_000u64);
1794
1795 let order = make_signed_swap();
1796 let opts = ExecutionOptions {
1797 dry_run: true,
1798 storage_overrides: Some(overrides),
1799 fetch_revert_reason: false,
1800 };
1801 let receipt = client
1802 .execute_swap(order, &opts)
1803 .await
1804 .expect("execute with overrides should succeed");
1805 receipt.await.expect("should resolve");
1806 }
1807
1808 #[tokio::test]
1809 async fn execute_dry_run_returns_simulation_failed_on_call_error() {
1810 let sender = Address::with_last_byte(0xab);
1811 let (client, asserter) =
1812 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1813
1814 asserter.push_failure_msg("execution reverted");
1815
1816 let order = make_signed_swap();
1817 let opts =
1818 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1819 let result = client.execute_swap(order, &opts).await;
1820 let err = match result {
1821 Err(e) => e,
1822 Ok(_) => panic!("expected SimulationFailed error"),
1823 };
1824
1825 assert!(
1826 matches!(err, FyndError::SimulationFailed(_)),
1827 "expected SimulationFailed, got {err:?}"
1828 );
1829 }
1830
1831 #[tokio::test]
1832 async fn execute_dry_run_with_empty_return_data_has_no_settled_amount() {
1833 let sender = Address::with_last_byte(0xab);
1834 let (client, asserter) =
1835 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
1836
1837 asserter.push_success(&alloy::primitives::Bytes::new());
1838 asserter.push_success(&21_000u64);
1839
1840 let order = make_signed_swap();
1841 let opts =
1842 ExecutionOptions { dry_run: true, storage_overrides: None, fetch_revert_reason: false };
1843 let receipt = client
1844 .execute_swap(order, &opts)
1845 .await
1846 .expect("execute should succeed");
1847 let settled = receipt.await.expect("should resolve");
1848
1849 assert!(
1850 settled.settled_amount().is_none(),
1851 "empty return data should yield None settled_amount"
1852 );
1853 }
1854
1855 #[tokio::test]
1856 async fn swap_payload_returns_protocol_error_when_no_transaction() {
1857 use crate::types::{BackendKind, BlockInfo, QuoteStatus};
1858
1859 let sender = Address::with_last_byte(0xab);
1860 let (client, _asserter) =
1861 make_test_client("http://localhost".to_string(), RetryConfig::default(), None);
1862
1863 let quote = crate::types::Quote::new(
1865 "no-tx".to_string(),
1866 QuoteStatus::Success,
1867 BackendKind::Fynd,
1868 None,
1869 num_bigint::BigUint::from(1_000u64),
1870 num_bigint::BigUint::from(990u64),
1871 num_bigint::BigUint::from(50_000u64),
1872 num_bigint::BigUint::from(940u64),
1873 None,
1874 BlockInfo::new(1, "0xabc".to_string(), 0),
1875 bytes::Bytes::copy_from_slice(&[0xbb; 20]),
1876 bytes::Bytes::copy_from_slice(&[0xcc; 20]),
1877 None,
1878 None,
1879 );
1880 let hints = SigningHints {
1881 sender: Some(sender),
1882 nonce: Some(1),
1883 max_fee_per_gas: Some(1_000_000_000),
1884 max_priority_fee_per_gas: Some(1_000_000),
1885 gas_limit: Some(100_000),
1886 simulate: false,
1887 };
1888
1889 let err = client
1890 .swap_payload(quote, &hints)
1891 .await
1892 .unwrap_err();
1893
1894 assert!(
1895 matches!(err, FyndError::Protocol(_)),
1896 "expected Protocol error when quote has no transaction, got {err:?}"
1897 );
1898 }
1899
1900 fn make_quote_params() -> QuoteParams {
1905 use crate::types::{Order, OrderSide, QuoteOptions};
1906
1907 let token_in = bytes::Bytes::copy_from_slice(&[0xaa; 20]);
1908 let token_out = bytes::Bytes::copy_from_slice(&[0xbb; 20]);
1909 let sender = bytes::Bytes::copy_from_slice(&[0xcc; 20]);
1910
1911 let order = Order::new(
1912 token_in,
1913 token_out,
1914 num_bigint::BigUint::from(1_000_000u64),
1915 OrderSide::Sell,
1916 sender,
1917 None,
1918 );
1919
1920 QuoteParams::new(order, QuoteOptions::default())
1921 }
1922
1923 fn make_info_body() -> serde_json::Value {
1928 serde_json::json!({
1929 "chain_id": 1,
1930 "router_address": "0x0101010101010101010101010101010101010101",
1931 "permit2_address": "0x0202020202020202020202020202020202020202"
1932 })
1933 }
1934
1935 #[tokio::test]
1936 async fn info_fetches_and_caches() {
1937 use wiremock::{
1938 matchers::{method, path},
1939 Mock, MockServer, ResponseTemplate,
1940 };
1941
1942 let server = MockServer::start().await;
1943
1944 Mock::given(method("GET"))
1945 .and(path("/v1/info"))
1946 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
1947 .expect(1) .mount(&server)
1949 .await;
1950
1951 let (client, _asserter) = make_test_client(server.uri(), RetryConfig::default(), None);
1952
1953 let info1 = client
1954 .info()
1955 .await
1956 .expect("first info call should succeed");
1957 let info2 = client
1958 .info()
1959 .await
1960 .expect("second info call should use cache");
1961
1962 assert_eq!(info1.chain_id(), 1);
1963 assert_eq!(info2.chain_id(), 1);
1964 assert_eq!(info1.router_address().as_ref(), &[0x01u8; 20]);
1965 assert_eq!(info1.permit2_address().as_ref(), &[0x02u8; 20]);
1966 }
1968
1969 #[tokio::test]
1974 async fn approval_builds_correct_calldata() {
1975 use wiremock::{
1976 matchers::{method, path},
1977 Mock, MockServer, ResponseTemplate,
1978 };
1979
1980 let server = MockServer::start().await;
1981
1982 Mock::given(method("GET"))
1983 .and(path("/v1/info"))
1984 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
1985 .expect(1)
1986 .mount(&server)
1987 .await;
1988
1989 let sender = Address::with_last_byte(0xab);
1990 let (client, asserter) =
1991 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
1992
1993 let hints = SigningHints {
1995 sender: Some(sender),
1996 nonce: Some(3),
1997 max_fee_per_gas: Some(2_000_000_000),
1998 max_priority_fee_per_gas: Some(1_000_000),
1999 gas_limit: None, simulate: false,
2001 };
2002 asserter.push_success(&65_000u64);
2004
2005 let params = ApprovalParams::new(
2006 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2007 num_bigint::BigUint::from(1_000_000u64),
2008 AllowanceCheck::Skip,
2009 );
2010
2011 let payload = client
2012 .approval(¶ms, &hints)
2013 .await
2014 .expect("approval should succeed")
2015 .expect("should build payload when AllowanceCheck::Skip");
2016
2017 let selector = &payload.tx().input[0..4];
2019 assert_eq!(selector, &[0x09, 0x5e, 0xa7, 0xb3]);
2020 assert_eq!(payload.tx().gas_limit, 65_000, "gas limit should come from eth_estimateGas");
2021 assert_eq!(payload.tx().nonce, 3);
2022 }
2023
2024 #[tokio::test]
2025 async fn approval_with_insufficient_allowance_returns_some() {
2026 use wiremock::{
2027 matchers::{method, path},
2028 Mock, MockServer, ResponseTemplate,
2029 };
2030
2031 let server = MockServer::start().await;
2032
2033 Mock::given(method("GET"))
2034 .and(path("/v1/info"))
2035 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2036 .expect(1)
2037 .mount(&server)
2038 .await;
2039
2040 let sender = Address::with_last_byte(0xab);
2041 let (client, asserter) =
2042 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2043
2044 let hints = SigningHints {
2045 sender: Some(sender),
2046 nonce: Some(0),
2047 max_fee_per_gas: Some(1_000_000_000),
2048 max_priority_fee_per_gas: Some(1_000_000),
2049 gas_limit: None,
2050 simulate: false,
2051 };
2052
2053 let zero_allowance = alloy::primitives::Bytes::copy_from_slice(&[0u8; 32]);
2055 asserter.push_success(&zero_allowance);
2056 asserter.push_success(&65_000u64);
2058
2059 let params = ApprovalParams::new(
2060 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2061 num_bigint::BigUint::from(500_000u64),
2062 AllowanceCheck::AtLeast(num_bigint::BigUint::from(500_000u64)),
2063 );
2064
2065 let result = client
2066 .approval(¶ms, &hints)
2067 .await
2068 .expect("approval with allowance check should succeed");
2069
2070 assert!(result.is_some(), "zero allowance should return a payload");
2071 }
2072
2073 #[tokio::test]
2074 async fn approval_with_sufficient_allowance_returns_none() {
2075 use wiremock::{
2076 matchers::{method, path},
2077 Mock, MockServer, ResponseTemplate,
2078 };
2079
2080 let server = MockServer::start().await;
2081
2082 Mock::given(method("GET"))
2083 .and(path("/v1/info"))
2084 .respond_with(ResponseTemplate::new(200).set_body_json(make_info_body()))
2085 .expect(1)
2086 .mount(&server)
2087 .await;
2088
2089 let sender = Address::with_last_byte(0xab);
2090 let (client, asserter) =
2091 make_test_client(server.uri(), RetryConfig::default(), Some(sender));
2092
2093 let hints = SigningHints {
2094 sender: Some(sender),
2095 nonce: Some(0),
2096 max_fee_per_gas: Some(1_000_000_000),
2097 max_priority_fee_per_gas: Some(1_000_000),
2098 gas_limit: None,
2099 simulate: false,
2100 };
2101
2102 let mut allowance_bytes = [0u8; 32];
2104 allowance_bytes[24..32].copy_from_slice(&1_000_000u64.to_be_bytes());
2106 asserter.push_success(&alloy::primitives::Bytes::copy_from_slice(&allowance_bytes));
2107
2108 let params = ApprovalParams::new(
2110 bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2111 num_bigint::BigUint::from(500_000u64),
2112 AllowanceCheck::AtLeast(num_bigint::BigUint::from(500_000u64)),
2113 );
2114
2115 let result = client
2116 .approval(¶ms, &hints)
2117 .await
2118 .expect("approval with sufficient allowance check should succeed");
2119
2120 assert!(result.is_none(), "sufficient allowance should return None");
2121 }
2122
2123 fn make_signed_approval() -> crate::signing::SignedApproval {
2128 use alloy::primitives::{Signature, TxKind, U256};
2129
2130 use crate::signing::ApprovalPayload;
2131
2132 let tx = TxEip1559 {
2133 chain_id: 1,
2134 nonce: 0,
2135 max_fee_per_gas: 1_000_000_000,
2136 max_priority_fee_per_gas: 1_000_000,
2137 gas_limit: 65_000,
2138 to: TxKind::Call(Address::ZERO),
2139 value: U256::ZERO,
2140 input: AlloyBytes::from(vec![0x09, 0x5e, 0xa7, 0xb3]),
2141 access_list: AccessList::default(),
2142 };
2143 let payload = ApprovalPayload {
2144 tx,
2145 token: bytes::Bytes::copy_from_slice(&[0xdd; 20]),
2146 spender: bytes::Bytes::copy_from_slice(&[0x01; 20]),
2147 amount: num_bigint::BigUint::from(1_000_000u64),
2148 };
2149 SignedApproval::assemble(payload, Signature::test_signature())
2150 }
2151
2152 #[tokio::test]
2153 async fn execute_approval_broadcasts_and_polls() {
2154 let sender = Address::with_last_byte(0xab);
2155 let (client, asserter) =
2156 make_test_client("http://localhost".to_string(), RetryConfig::default(), Some(sender));
2157
2158 let tx_hash = alloy::primitives::B256::repeat_byte(0xef);
2160 asserter.push_success(&tx_hash);
2161
2162 asserter.push_success::<Option<()>>(&None);
2164 let receipt = alloy::rpc::types::TransactionReceipt {
2165 inner: alloy::consensus::ReceiptEnvelope::Eip1559(alloy::consensus::ReceiptWithBloom {
2166 receipt: alloy::consensus::Receipt::<alloy::primitives::Log> {
2167 status: alloy::consensus::Eip658Value::Eip658(true),
2168 cumulative_gas_used: 50_000,
2169 logs: vec![],
2170 },
2171 logs_bloom: alloy::primitives::Bloom::default(),
2172 }),
2173 transaction_hash: tx_hash,
2174 transaction_index: None,
2175 block_hash: None,
2176 block_number: None,
2177 gas_used: 45_000,
2178 effective_gas_price: 1_500_000_000,
2179 blob_gas_used: None,
2180 blob_gas_price: None,
2181 from: Address::ZERO,
2182 to: None,
2183 contract_address: None,
2184 };
2185 asserter.push_success(&receipt);
2186
2187 let approval = make_signed_approval();
2188 let tx_receipt = client
2189 .execute_approval(approval)
2190 .await
2191 .expect("execute_approval should succeed");
2192
2193 let mined = tx_receipt
2194 .await
2195 .expect("receipt should resolve");
2196
2197 assert_eq!(mined.tx_hash(), tx_hash);
2198 let expected_cost =
2199 num_bigint::BigUint::from(45_000u64) * num_bigint::BigUint::from(1_500_000_000u64);
2200 assert_eq!(mined.gas_cost(), &expected_cost);
2201 }
2202}