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