1use std::sync::Arc;
2
3use alloy::{
4 primitives::{aliases::U48, keccak256, Address, Keccak256, U160, U256},
5 sol_types::SolValue,
6};
7use metrics::counter;
8use num_bigint::BigUint;
9use tycho_execution::encoding::{
10 errors::EncodingError,
11 evm::{
12 approvals::permit2::{PermitDetails as SolPermitDetails, PermitSingle},
13 encoder_builders::TychoRouterEncoderBuilder,
14 get_router_address,
15 swap_encoder::swap_encoder_registry::SwapEncoderRegistry,
16 utils::{biguint_to_u256, bytes_to_address},
17 ROUTER_ETH_ADDRESS,
18 },
19 models::{EncodedSolution, Solution, Swap},
20 tycho_encoder::TychoEncoder,
21};
22use tycho_simulation::tycho_common::{models::Chain, Bytes};
23
24use crate::{
25 encoding::{
26 exclusive_swap::ExclusiveSwapSigner,
27 router_fees::{FeeRates, SharedRouterFees},
28 },
29 EncodingOptions, FeeBreakdown, OrderQuote, QuoteStatus, SolveError, Transaction,
30};
31
32pub const PERMIT2_ADDRESS: &str = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
34
35const MAX_SLIPPAGE_TOLERANCE_BPS: u64 = 2_000;
38
39const BPS_DENOMINATOR: u64 = 10_000;
41
42pub struct Encoder {
52 tycho_encoder: Option<Box<dyn TychoEncoder>>,
53 chain: Chain,
54 router_address: Option<Bytes>,
55 router_fees: SharedRouterFees,
56 exclusive_swap_signer: Option<ExclusiveSwapSigner>,
58 calldata_watermark: Option<Vec<u8>>,
62}
63
64impl TryFrom<&OrderQuote> for Solution {
70 type Error = SolveError;
71
72 fn try_from(quote: &OrderQuote) -> Result<Self, Self::Error> {
73 solution_from_quote(quote, quote.amount_out().clone())
74 }
75}
76
77fn solution_from_quote(
82 quote: &OrderQuote,
83 min_amount_out: BigUint,
84) -> Result<Solution, SolveError> {
85 if quote.status() != QuoteStatus::Success {
86 return Err(SolveError::FailedEncoding(format!(
87 "cannot convert quote with status {:?} to Solution",
88 quote.status()
89 )));
90 }
91
92 let route = quote.route().ok_or_else(|| {
93 SolveError::FailedEncoding("successful quote must have a route".to_string())
94 })?;
95
96 let token_in = route
97 .input_token()
98 .ok_or_else(|| SolveError::FailedEncoding("route has no input token".to_string()))?;
99 let token_out = route
100 .output_token()
101 .ok_or_else(|| SolveError::FailedEncoding("route has no output token".to_string()))?;
102
103 let token_map = route.tokens();
104 let lookup_token = |addr: &Bytes| {
105 token_map
106 .get(addr)
107 .cloned()
108 .ok_or_else(|| {
109 SolveError::FailedEncoding(format!(
110 "token {addr:?} not found in route's token map; \
111 algorithm must populate Route::with_tokens for every swap token"
112 ))
113 })
114 };
115 let swaps = route
116 .swaps()
117 .iter()
118 .map(|s| {
119 let token_in = lookup_token(s.token_in())?;
120 let token_out = lookup_token(s.token_out())?;
121 Ok(Swap::new(
122 s.protocol_component().clone(),
123 token_in,
124 token_out,
125 s.gas_estimate().clone(),
126 )
127 .with_split(*s.split())
128 .with_protocol_state(Arc::from(s.protocol_state().clone_box()))
129 .with_estimated_amount_in(s.amount_in().clone()))
130 })
131 .collect::<Result<Vec<_>, SolveError>>()?;
132
133 Ok(Solution::new(
134 quote.sender().clone(),
135 quote.receiver().clone(),
136 Bytes::from(token_in.as_ref()),
137 Bytes::from(token_out.as_ref()),
138 quote.amount_in().clone(),
139 quote.amount_out().clone(),
140 min_amount_out,
141 swaps,
142 ))
143}
144
145impl Encoder {
146 pub fn is_supported(chain: Chain) -> bool {
148 get_router_address(&chain).is_ok()
149 }
150
151 pub fn new(
162 chain: Chain,
163 swap_encoder_registry: SwapEncoderRegistry,
164 ) -> Result<Self, SolveError> {
165 let router_address = get_router_address(&chain).ok().cloned();
166 let tycho_encoder = router_address
167 .is_some()
168 .then(|| {
169 TychoRouterEncoderBuilder::new()
170 .chain(chain)
171 .swap_encoder_registry(swap_encoder_registry)
172 .build()
173 })
174 .transpose()?;
175 let exclusive_swap_signer = ExclusiveSwapSigner::from_env(chain.id())?;
176 Ok(Self {
177 tycho_encoder,
178 chain,
179 router_address,
180 router_fees: SharedRouterFees::default(),
181 exclusive_swap_signer,
182 calldata_watermark: None,
183 })
184 }
185
186 #[must_use]
190 pub fn with_calldata_watermark(mut self, watermark: impl Into<Vec<u8>>) -> Self {
191 self.calldata_watermark = Some(watermark.into());
192 self
193 }
194
195 #[must_use]
197 pub fn with_exclusive_swap_signer(mut self, signer: ExclusiveSwapSigner) -> Self {
198 self.exclusive_swap_signer = Some(signer);
199 self
200 }
201
202 pub fn router_address(&self) -> Option<&Bytes> {
205 self.router_address.as_ref()
206 }
207
208 pub fn chain(&self) -> Chain {
210 self.chain
211 }
212
213 pub fn router_fees(&self) -> SharedRouterFees {
218 self.router_fees.clone()
219 }
220
221 pub async fn encode(
230 &self,
231 mut quotes: Vec<OrderQuote>,
232 encoding_options: EncodingOptions,
233 ) -> Result<Vec<OrderQuote>, SolveError> {
234 let Some(tycho_encoder) = self.tycho_encoder.as_ref() else {
235 return Err(SolveError::EncodingUnavailable(format!(
236 "encoding is unavailable on chain '{}': no Tycho router is deployed. Fynd is \
237 running quote-only; contact ops to deploy the router/executor contracts.",
238 self.chain
239 )));
240 };
241
242 let slippage = encoding_options.slippage();
243 if slippage == 0.0 {
244 tracing::warn!("slippage is 0, transaction will likely revert");
245 } else if slippage > 0.5 {
246 tracing::warn!(slippage, "slippage exceeds 50%, possible misconfiguration");
247 }
248
249 let router_fees = self.router_fees.snapshot();
250 let mut to_encode: Vec<(usize, Solution, FeeBreakdown, FeeRates)> = Vec::new();
251
252 for (i, quote) in quotes.iter_mut().enumerate() {
253 if quote.status() != QuoteStatus::Success {
254 continue;
255 }
256
257 let fee_client = encoding_options
261 .client_fee_params()
262 .map_or_else(|| quote.sender(), |f| f.receiver());
263 let fee_rates = router_fees.fees_for(fee_client);
264 let fee_breakdown = Self::calculate_fee_breakdown(
265 quote.amount_out(),
266 encoding_options
267 .client_fee_params()
268 .map_or(0, |f| f.bps()),
269 slippage,
270 fee_rates,
271 )?;
272 Self::check_slippage_guardrail(
273 biguint_to_u256(quote.amount_out()),
274 biguint_to_u256(fee_breakdown.min_amount_received()),
275 )?;
276
277 let solution = solution_from_quote(
278 quote,
279 fee_breakdown
280 .min_amount_received()
281 .clone(),
282 )?
283 .with_user_transfer_type(encoding_options.transfer_type().clone());
284 let solution = match &self.exclusive_swap_signer {
285 Some(signer) => Self::stamp_exclusive_swaps(solution, quote, signer)?,
286 None => {
287 if has_exclusive_leg(quote) {
290 return Err(SolveError::FailedEncoding(
291 "quote routes through an exclusive pool but no signing key is \
292 configured (set EXCLUSIVE_SWAP_CONTROLLER_KEY)"
293 .to_string(),
294 ));
295 }
296 solution
297 }
298 };
299
300 let fallback_amount_out = quote
306 .route()
307 .and_then(|route| route.fallback_amount_out())
308 .cloned();
309 if let Some(fallback) = &fallback_amount_out {
310 if !Self::fallback_clears_floor(
311 fee_breakdown.min_amount_received(),
312 fallback,
313 encoding_options
314 .client_fee_params()
315 .map_or(0, |f| f.bps()),
316 fee_rates,
317 )? {
318 counter!("propamm_fallback_quotes_total", "outcome" => "dropped").increment(1);
319 tracing::debug!(
320 order_id = %quote.order_id(),
321 %fallback,
322 slippage,
323 "dropping pAMM quote: the Uniswap V3 fallback pays less than the \
324 user's accepted slippage"
325 );
326 quote.set_status(QuoteStatus::NoRouteFound);
327 continue;
328 }
329 counter!("propamm_fallback_quotes_total", "outcome" => "kept").increment(1);
330 }
331 to_encode.push((i, solution, fee_breakdown, fee_rates));
332 }
333
334 let solutions: Vec<Solution> = to_encode
335 .iter()
336 .map(|(_, s, _, _)| s.clone())
337 .collect();
338 let encoded_solutions = tycho_encoder.encode_solutions(solutions)?;
339
340 for (encoded_solution, (idx, solution, fee_breakdown, fee_rates)) in encoded_solutions
341 .into_iter()
342 .zip(to_encode)
343 {
344 quotes[idx].set_gas_estimate(encoded_solution.estimated_gas().clone());
345 let (transaction, fee_breakdown) = self.encode_tycho_router_call(
346 encoded_solution,
347 &solution,
348 &encoding_options,
349 fee_breakdown,
350 fee_rates,
351 )?;
352 quotes[idx].set_transaction(transaction);
353 quotes[idx].set_fee_breakdown(fee_breakdown);
354 }
355
356 Ok(quotes)
357 }
358
359 fn stamp_exclusive_swaps(
365 solution: Solution,
366 quote: &OrderQuote,
367 signer: &ExclusiveSwapSigner,
368 ) -> Result<Solution, SolveError> {
369 let route = quote.route().ok_or_else(|| {
370 SolveError::FailedEncoding("successful quote must have a route".to_string())
371 })?;
372 let route_swaps = route.swaps();
373
374 if !route_swaps
376 .iter()
377 .any(|s| s.committed_amount_out().is_some())
378 {
379 return Ok(solution);
380 }
381
382 let swaps = solution
388 .swaps()
389 .iter()
390 .cloned()
391 .zip(route_swaps.iter())
392 .map(|(solution_swap, route_swap)| {
395 if route_swap
396 .committed_amount_out()
397 .is_some()
398 {
399 let user_data = signer.build_user_data(route_swap)?;
400 Ok(solution_swap.with_user_data(user_data))
401 } else {
402 Ok(solution_swap)
403 }
404 })
405 .collect::<Result<Vec<_>, SolveError>>()?;
406
407 Ok(solution.with_swaps(swaps))
408 }
409
410 fn encode_tycho_router_call(
420 &self,
421 encoded_solution: EncodedSolution,
422 solution: &Solution,
423 encoding_options: &EncodingOptions,
424 fee_breakdown: FeeBreakdown,
425 fee_rates: FeeRates,
426 ) -> Result<(Transaction, FeeBreakdown), EncodingError> {
427 let amount_in = biguint_to_u256(solution.amount_in());
428 let expected_amount_out = biguint_to_u256(solution.expected_amount_out());
429 let min_amount_out = biguint_to_u256(solution.min_amount_out());
430 let native_address = &self.chain.native_token().address;
431 let router_eth = Address::from_slice(ROUTER_ETH_ADDRESS.as_ref());
432 let to_router_address = |raw: Address| {
433 if raw.as_slice() == native_address.as_ref() {
434 router_eth
435 } else {
436 raw
437 }
438 };
439
440 let token_in = to_router_address(bytes_to_address(solution.token_in())?);
441 let token_out = to_router_address(bytes_to_address(solution.token_out())?);
442 let receiver = bytes_to_address(solution.receiver())?;
443
444 let (permit, permit2_sig) = if let Some(p) = encoding_options.permit() {
445 let d = p.details();
446 let permit = Some(PermitSingle {
447 details: SolPermitDetails {
448 token: bytes_to_address(d.token())?,
449 amount: U160::from(biguint_to_u256(d.amount())),
450 expiration: U48::from(biguint_to_u256(d.expiration())),
451 nonce: U48::from(biguint_to_u256(d.nonce())),
452 },
453 spender: bytes_to_address(p.spender())?,
454 sigDeadline: biguint_to_u256(p.sig_deadline()),
455 });
456 let sig = encoding_options
457 .permit2_signature()
458 .ok_or_else(|| {
459 EncodingError::FatalError("Signature must be provided for permit2".to_string())
460 })?
461 .to_vec();
462 (permit, sig)
463 } else {
464 (None, vec![])
465 };
466
467 let client_fee_params = if let Some(fee) = encoding_options.client_fee_params() {
468 let fee_units = fee_rates.client_fee_units(fee.bps());
471 let fee_units = u32::try_from(fee_units).map_err(|_| {
472 EncodingError::FatalError(format!(
473 "client fee ({} bps) scales to {fee_units} fee units, which overflows the \
474 router's uint32 clientFeeBps",
475 fee.bps()
476 ))
477 })?;
478 (
479 fee_units,
480 bytes_to_address(fee.receiver())?,
481 biguint_to_u256(fee.max_contribution()),
482 U256::from(fee.deadline()),
483 {
486 let mut sig = fee.signature().to_vec();
487 sig.resize(65, 0);
488 sig
489 },
490 )
491 } else {
492 (0u32, Address::ZERO, U256::ZERO, U256::MAX, vec![])
493 };
494
495 let fn_sig = encoded_solution.function_signature();
496 let swaps = encoded_solution.swaps();
497 let fee_breakdown = if encoding_options
498 .client_fee_params()
499 .is_some()
500 {
501 fee_breakdown.with_swaps_hash(keccak256(swaps).0)
502 } else {
503 fee_breakdown
504 };
505
506 let method_calldata = if fn_sig.contains("Permit2") {
507 let permit = permit.ok_or(EncodingError::FatalError(
508 "permit2 object must be set to use permit2".to_string(),
509 ))?;
510 if fn_sig.contains("splitSwap") {
511 (
512 amount_in,
513 token_in,
514 token_out,
515 expected_amount_out,
516 min_amount_out,
517 U256::from(encoded_solution.n_tokens()),
518 receiver,
519 client_fee_params,
520 permit,
521 permit2_sig,
522 swaps,
523 )
524 .abi_encode()
525 } else {
526 (
527 amount_in,
528 token_in,
529 token_out,
530 expected_amount_out,
531 min_amount_out,
532 receiver,
533 client_fee_params,
534 permit,
535 permit2_sig,
536 swaps,
537 )
538 .abi_encode()
539 }
540 } else if fn_sig.contains("splitSwap") {
541 (
542 amount_in,
543 token_in,
544 token_out,
545 expected_amount_out,
546 min_amount_out,
547 U256::from(encoded_solution.n_tokens()),
548 receiver,
549 client_fee_params,
550 swaps,
551 )
552 .abi_encode()
553 } else if fn_sig.contains("singleSwap") || fn_sig.contains("sequentialSwap") {
554 (
555 amount_in,
556 token_in,
557 token_out,
558 expected_amount_out,
559 min_amount_out,
560 receiver,
561 client_fee_params,
562 swaps,
563 )
564 .abi_encode()
565 } else {
566 return Err(EncodingError::FatalError(format!(
567 "unsupported function signature for Tycho router: {fn_sig}"
568 )));
569 };
570
571 let mut contract_interaction =
572 Self::encode_input(encoded_solution.function_signature(), method_calldata);
573 if let Some(watermark) = &self.calldata_watermark {
574 contract_interaction.extend_from_slice(watermark);
575 }
576
577 let value =
578 if token_in == router_eth { solution.amount_in().clone() } else { BigUint::ZERO };
579 let mut transaction = Transaction::new(
580 encoded_solution
581 .interacting_with()
582 .clone(),
583 value,
584 contract_interaction,
585 );
586 if encoding_options
587 .client_fee_params()
588 .is_some()
589 {
590 let offset = encoded_solution.client_fee_signature_offset();
591 transaction = transaction.with_client_fee_signature_offset(offset);
592 }
593 Ok((transaction, fee_breakdown))
594 }
595
596 fn check_slippage_guardrail(
602 expected_amount_out: U256,
603 min_amount_out: U256,
604 ) -> Result<(), EncodingError> {
605 let floor = expected_amount_out * U256::from(BPS_DENOMINATOR - MAX_SLIPPAGE_TOLERANCE_BPS) /
606 U256::from(BPS_DENOMINATOR);
607 if min_amount_out > expected_amount_out || min_amount_out < floor {
608 return Err(EncodingError::FatalError(format!(
609 "minimum amount out {min_amount_out} is outside the router's accepted range \
610 [{floor}, {expected_amount_out}] for the quoted output; reduce slippage or the \
611 client fee"
612 )));
613 }
614 Ok(())
615 }
616
617 fn encode_input(selector: &str, mut encoded_args: Vec<u8>) -> Vec<u8> {
619 let mut hasher = Keccak256::new();
620 hasher.update(selector.as_bytes());
621 let selector_bytes = &hasher.finalize()[..4];
622 let mut call_data = selector_bytes.to_vec();
623 if encoded_args.len() > 32 &&
627 encoded_args[..32] ==
628 [0u8; 31]
629 .into_iter()
630 .chain([32].to_vec())
631 .collect::<Vec<u8>>()
632 {
633 encoded_args = encoded_args[32..].to_vec();
634 }
635 call_data.extend(encoded_args);
636 call_data
637 }
638
639 fn fallback_clears_floor(
650 floor: &BigUint,
651 fallback_amount_out: &BigUint,
652 client_fee_bps: u16,
653 fee_rates: FeeRates,
654 ) -> Result<bool, EncodingError> {
655 let fallback_fill =
656 Self::calculate_fee_breakdown(fallback_amount_out, client_fee_bps, 0.0, fee_rates)?;
657 Ok(fallback_fill.min_amount_received() >= floor)
658 }
659
660 fn calculate_fee_breakdown(
671 swap_output: &BigUint,
672 client_fee_bps: u16,
673 slippage: f64,
674 fee_rates: FeeRates,
675 ) -> Result<FeeBreakdown, EncodingError> {
676 let max_fee_units = fee_rates.max_fee_units();
677 let scaled_client_fee = fee_rates.client_fee_units(client_fee_bps);
680 let fee_on_output = fee_rates.on_output() as u64;
681 let fee_on_client_fee = fee_rates.on_client_fee() as u64;
682
683 if scaled_client_fee + fee_on_output > max_fee_units {
684 return Err(EncodingError::FatalError(format!(
685 "client fee ({client_fee_bps} bps) plus router fee on output \
686 ({fee_on_output} fee units) exceed the {max_fee_units} fee-unit cap (100%); \
687 the router would revert"
688 )));
689 }
690 if fee_on_client_fee > max_fee_units {
691 return Err(EncodingError::FatalError(format!(
692 "router fee on client fee ({fee_on_client_fee} fee units) exceeds the \
693 {max_fee_units} fee-unit cap (100%); the router would revert"
694 )));
695 }
696
697 let mut router_fee_on_client = BigUint::ZERO;
698 let mut client_portion = BigUint::ZERO;
699
700 if scaled_client_fee > 0 {
701 let client_fee_numerator = swap_output * scaled_client_fee;
702 let total_client_fee = &client_fee_numerator / max_fee_units;
703
704 router_fee_on_client = client_fee_numerator * fee_on_client_fee /
705 BigUint::from(fee_rates.max_fee_units_squared());
706
707 client_portion = total_client_fee - &router_fee_on_client;
708 }
709
710 let router_fee_on_output = swap_output * fee_on_output / max_fee_units;
711 let total_router_fee = router_fee_on_client + router_fee_on_output;
712
713 let amount_after_fees = swap_output - &client_portion - &total_router_fee;
714
715 let precision = BigUint::from(1_000_000u64);
716 let slippage_amount =
717 &amount_after_fees * BigUint::from((slippage * 1_000_000.0) as u64) / &precision;
718
719 let min_amount_received = &amount_after_fees - &slippage_amount;
720
721 Ok(FeeBreakdown::new(
722 total_router_fee,
723 client_portion,
724 slippage_amount,
725 min_amount_received,
726 ))
727 }
728}
729
730impl From<EncodingError> for SolveError {
731 fn from(err: EncodingError) -> Self {
732 SolveError::FailedEncoding(err.to_string())
733 }
734}
735
736fn has_exclusive_leg(quote: &OrderQuote) -> bool {
739 quote.route().is_some_and(|route| {
740 route
741 .swaps()
742 .iter()
743 .any(|s| s.committed_amount_out().is_some())
744 })
745}
746
747#[cfg(test)]
748mod tests {
749 use alloy::primitives::{Address as EvmAddress, Bytes as EvmBytes};
750 use num_bigint::BigUint;
751 use rstest::rstest;
752 use rustc_hash::FxHashMap;
753 use tycho_execution::encoding::{
754 errors::EncodingError,
755 models::{EncodedSolution, Solution},
756 tycho_encoder::TychoEncoder,
757 };
758 use tycho_simulation::tycho_core::{
759 models::{token::Token, Address, Chain as SimChain},
760 Bytes,
761 };
762
763 use super::*;
764 use crate::{
765 algorithm::test_utils::{component, MockProtocolSim},
766 encoding::router_fees::RouterFees,
767 BlockInfo, OrderQuote, QuoteStatus,
768 };
769
770 fn make_token(addr: Address) -> Token {
771 Token {
772 address: addr,
773 symbol: "T".to_string(),
774 decimals: 18,
775 tax: Default::default(),
776 gas: vec![],
777 chain: SimChain::Ethereum,
778 quality: 100,
779 }
780 }
781
782 fn make_route_swap_addrs(token_in: Address, token_out: Address) -> crate::types::Swap {
783 let tin = make_token(token_in.clone());
784 let tout = make_token(token_out.clone());
785 let component_addr = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
787 crate::types::Swap::new(
788 component_addr.to_string(),
789 "uniswap_v2".to_string(),
790 token_in,
791 token_out,
792 BigUint::from(1000u64),
793 BigUint::from(990u64),
794 BigUint::from(50_000u64),
795 component(component_addr, &[tin, tout]),
796 Box::new(MockProtocolSim::default()),
797 )
798 }
799
800 fn make_route_with_tokens(pairs: &[(Address, Address)]) -> crate::types::Route {
803 let mut tokens = rustc_hash::FxHashMap::default();
804 let swaps = pairs
805 .iter()
806 .map(|(tin, tout)| {
807 tokens
808 .entry(tin.clone())
809 .or_insert_with(|| make_token(tin.clone()));
810 tokens
811 .entry(tout.clone())
812 .or_insert_with(|| make_token(tout.clone()));
813 make_route_swap_addrs(tin.clone(), tout.clone())
814 })
815 .collect();
816 crate::types::Route::new(swaps, tokens).expect("non-empty route")
817 }
818
819 fn make_address(byte: u8) -> Address {
820 Address::from([byte; 20])
821 }
822
823 fn make_order_quote(amount_out: u64) -> OrderQuote {
824 OrderQuote::new(
825 "test-order".to_string(),
826 QuoteStatus::Success,
827 BigUint::from(1000u64),
828 BigUint::from(amount_out),
829 BigUint::from(100_000u64),
830 BigUint::from(amount_out),
831 BlockInfo::new(1, "0x123".to_string(), 1000),
832 "test".to_string(),
833 Bytes::from(make_address(0xAA).as_ref()),
834 Bytes::from(make_address(0xAA).as_ref()),
835 "1".to_string(),
836 )
837 }
838
839 struct MockTychoEncoder;
840
841 impl TychoEncoder for MockTychoEncoder {
842 fn encode_solutions(
843 &self,
844 _solutions: Vec<Solution>,
845 ) -> Result<Vec<EncodedSolution>, EncodingError> {
846 Ok(vec![])
847 }
848
849 fn validate_solution(&self, _solution: &Solution) -> Result<(), EncodingError> {
850 Ok(())
851 }
852 }
853
854 fn mock_encoder(chain: Chain) -> Encoder {
855 let router_fees = SharedRouterFees::default();
856 router_fees.set(RouterFees::new(
857 FEE_SCALE,
858 100_000,
859 20_000_000,
860 rustc_hash::FxHashMap::default(),
861 ));
862 Encoder {
863 tycho_encoder: Some(Box::new(MockTychoEncoder)),
864 chain,
865 router_address: Some(Bytes::from([0u8; 20].as_ref())),
866 router_fees,
867 exclusive_swap_signer: None,
868 calldata_watermark: None,
869 }
870 }
871
872 #[test]
873 fn test_encoder_new_disabled_on_unsupported_chain() {
874 let registry =
878 tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry::new(Chain::Ethereum)
879 .add_default_encoders(None)
880 .expect("registry should build for Ethereum");
881 let encoder = Encoder::new(Chain::Starknet, registry)
882 .expect("new must not fail for a router-less chain");
883 assert!(
884 encoder.router_address().is_none(),
885 "expected disabled encoder, got a router address"
886 );
887 }
888
889 #[tokio::test]
890 async fn disabled_encoder_quotes_but_refuses_to_encode() {
891 let registry = SwapEncoderRegistry::new(Chain::Ethereum)
893 .add_default_encoders(None)
894 .unwrap();
895 let encoder =
896 Encoder::new(Chain::Starknet, registry).expect("new must not fail when disabled");
897 assert!(encoder.router_address().is_none());
898
899 let err = encoder
900 .encode(vec![], EncodingOptions::new(0.01))
901 .await
902 .expect_err("encoding must fail on a router-less chain");
903 assert!(matches!(err, SolveError::EncodingUnavailable(_)));
904 }
905
906 #[test]
907 fn test_try_from_without_route_errors() {
908 let quote = make_order_quote(990);
909
910 let result = Solution::try_from("e);
911
912 assert!(result.is_err());
913 }
914
915 #[test]
916 fn test_try_from_non_success_errors() {
917 let quote = OrderQuote::new(
918 "test-order".to_string(),
919 QuoteStatus::NoRouteFound,
920 BigUint::from(1000u64),
921 BigUint::from(990u64),
922 BigUint::from(100_000u64),
923 BigUint::from(990u64),
924 BlockInfo::new(1, "0x123".to_string(), 1000),
925 "test".to_string(),
926 Bytes::from(make_address(0xAA).as_ref()),
927 Bytes::from(make_address(0xAA).as_ref()),
928 "1".to_string(),
929 );
930
931 let result = Solution::try_from("e);
932
933 assert!(result.is_err());
934 }
935
936 #[test]
937 fn test_try_from_maps_tokens_and_amounts() {
938 let quote = make_order_quote(990)
939 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
940
941 let solution = Solution::try_from("e).unwrap();
942
943 assert_eq!(*solution.token_in(), Bytes::from(make_address(0x01).as_ref()));
944 assert_eq!(*solution.token_out(), Bytes::from(make_address(0x02).as_ref()));
945 assert_eq!(*solution.amount_in(), *quote.amount_in());
946 assert_eq!(*solution.expected_amount_out(), *quote.amount_out());
947 assert_eq!(*solution.min_amount_out(), *quote.amount_out());
949 assert_eq!(solution.swaps().len(), 1);
950 }
951
952 const CONTROLLER_KEY: &str =
953 "0x1111111111111111111111111111111111111111111111111111111111111111";
954
955 fn ekubo_signed_swap(committed: Option<u64>) -> crate::types::Swap {
956 let token_in = make_address(0x11);
957 let token_out = make_address(0x22);
958 let mut comp = component("ekubo-signed-pool", &[]);
959 comp.static_attributes
960 .insert("extension".to_string(), Bytes::from([0x55u8; 20].as_ref()));
961 comp.static_attributes
962 .insert("fee".to_string(), Bytes::from(0u64));
963 comp.static_attributes
964 .insert("pool_type_config".to_string(), Bytes::from(0u32));
965
966 let mut swap = crate::types::Swap::new(
967 "ekubo-signed-pool".to_string(),
968 "ekubo_v3".to_string(),
969 token_in,
970 token_out,
971 BigUint::from(1_000_000u64),
972 BigUint::from(1_000_000u64),
973 BigUint::from(50_000u64),
974 comp,
975 Box::new(MockProtocolSim::default()),
976 );
977 if let Some(committed) = committed {
978 swap.set_committed_amount_out(BigUint::from(committed));
979 }
980 swap
981 }
982
983 fn single_swap_route(swap: crate::types::Swap) -> crate::types::Route {
984 let tokens = FxHashMap::from_iter([
985 (swap.token_in().clone(), make_token(swap.token_in().clone())),
986 (swap.token_out().clone(), make_token(swap.token_out().clone())),
987 ]);
988 crate::types::Route::new(vec![swap], tokens).expect("non-empty route")
989 }
990
991 #[rstest]
992 #[case::exclusive_leg_signed(Some(990_000), true)]
993 #[case::public_leg_untouched(None, false)]
994 fn test_stamp_exclusive_swaps(#[case] committed: Option<u64>, #[case] signed: bool) {
995 let quote =
996 make_order_quote(990_000).with_route(single_swap_route(ekubo_signed_swap(committed)));
997 let signer = ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 1, 0, 120);
998
999 let solution =
1000 Encoder::stamp_exclusive_swaps(Solution::try_from("e).unwrap(), "e, &signer)
1001 .unwrap();
1002
1003 assert_eq!(
1004 solution.swaps()[0]
1005 .user_data()
1006 .is_some(),
1007 signed
1008 );
1009 }
1010
1011 #[tokio::test]
1012 async fn test_encode_rejects_exclusive_leg_without_signer() {
1013 let encoder = mock_encoder(Chain::Ethereum);
1016 let quote = make_order_quote(990_000)
1017 .with_route(single_swap_route(ekubo_signed_swap(Some(990_000))));
1018
1019 let result = encoder
1020 .encode(vec![quote], EncodingOptions::new(0.01))
1021 .await;
1022
1023 assert!(result.is_err(), "expected fail-fast error for unsigned exclusive leg");
1024 }
1025
1026 #[test]
1027 fn test_try_from_multi_hop_uses_boundary_swap_tokens() {
1028 let quote = make_order_quote(990).with_route(make_route_with_tokens(&[
1029 (make_address(0x01), make_address(0x02)),
1030 (make_address(0x02), make_address(0x03)),
1031 ]));
1032
1033 let solution = Solution::try_from("e).unwrap();
1034
1035 assert_eq!(*solution.token_in(), Bytes::from(make_address(0x01).as_ref()));
1036 assert_eq!(*solution.token_out(), Bytes::from(make_address(0x03).as_ref()));
1037 assert_eq!(solution.swaps().len(), 2);
1038 }
1039
1040 #[tokio::test]
1041 async fn test_encode_skips_non_successful_solutions() {
1042 let encoder = mock_encoder(Chain::Ethereum);
1043 let quote = OrderQuote::new(
1044 "test-order".to_string(),
1045 QuoteStatus::NoRouteFound,
1046 BigUint::from(1000u64),
1047 BigUint::from(990u64),
1048 BigUint::from(100_000u64),
1049 BigUint::from(990u64),
1050 BlockInfo::new(1, "0x123".to_string(), 1000),
1051 "test".to_string(),
1052 Bytes::from(make_address(0xAA).as_ref()),
1053 Bytes::from(make_address(0xAA).as_ref()),
1054 "1".to_string(),
1055 );
1056
1057 let encoding_options = EncodingOptions::new(0.01);
1058
1059 let result = encoder
1060 .encode(vec![quote], encoding_options)
1061 .await
1062 .unwrap();
1063
1064 assert!(result[0].transaction().is_none());
1065 }
1066
1067 fn real_encoder() -> Encoder {
1068 let registry = SwapEncoderRegistry::new(Chain::Ethereum)
1069 .add_default_encoders(None)
1070 .unwrap();
1071 let encoder = Encoder::new(Chain::Ethereum, registry).unwrap();
1072 encoder
1074 .router_fees()
1075 .set(RouterFees::new(FEE_SCALE, 100_000, 20_000_000, rustc_hash::FxHashMap::default()));
1076 encoder
1077 }
1078
1079 #[tokio::test]
1080 async fn test_encode_sets_transaction_on_successful_solution() {
1081 let encoder = real_encoder();
1082 let quote = make_order_quote(990)
1083 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1084
1085 let encoding_options = EncodingOptions::new(0.01);
1086
1087 let result = encoder
1088 .encode(vec![quote], encoding_options)
1089 .await
1090 .unwrap();
1091
1092 assert!(result[0].transaction().is_some());
1093 let tx = result[0].transaction().unwrap();
1094 assert!(!tx.data().is_empty());
1095 assert!(tx.data().len() > 4);
1097 }
1098
1099 type SingleSwapCalldata = (
1102 U256,
1103 EvmAddress,
1104 EvmAddress,
1105 U256,
1106 U256,
1107 EvmAddress,
1108 (u32, EvmAddress, U256, U256, EvmBytes),
1109 EvmBytes,
1110 );
1111
1112 #[tokio::test]
1113 async fn test_encode_calldata_amounts_and_client_fee_units() {
1114 let encoder = real_encoder();
1115 let quote = make_order_quote(1_000_000_000)
1116 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1117 let amount_in = quote.amount_in().clone();
1118 let amount_out = quote.amount_out().clone();
1119 let opts = EncodingOptions::new(0.01).with_client_fee_params(make_client_fee(100));
1120
1121 let result = encoder
1122 .encode(vec![quote], opts)
1123 .await
1124 .unwrap();
1125
1126 let tx = result[0].transaction().unwrap();
1127 let breakdown = result[0].fee_breakdown().unwrap();
1128 let (encoded_amount_in, _, _, expected_amount_out, min_amount_out, _, client_fee, _) =
1129 <SingleSwapCalldata as SolValue>::abi_decode_params(&tx.data()[4..]).unwrap();
1130
1131 assert_eq!(encoded_amount_in, biguint_to_u256(&amount_in));
1132 assert_eq!(expected_amount_out, biguint_to_u256(&amount_out));
1134 assert_eq!(min_amount_out, biguint_to_u256(breakdown.min_amount_received()));
1135 assert!(min_amount_out < expected_amount_out);
1136 assert_eq!(client_fee.0, 1_000_000);
1138 }
1139
1140 #[tokio::test]
1141 async fn test_encode_rejects_slippage_beyond_router_guardrail() {
1142 let encoder = real_encoder();
1143 let quote = make_order_quote(1_000_000_000)
1144 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1145
1146 let err = encoder
1148 .encode(vec![quote], EncodingOptions::new(0.25))
1149 .await
1150 .expect_err("25% slippage must be rejected before it reaches the router");
1151
1152 assert!(
1153 err.to_string()
1154 .contains("outside the router's accepted range"),
1155 "got {err:?}"
1156 );
1157 }
1158
1159 #[tokio::test]
1160 async fn test_encode_with_client_fee_params() {
1161 let encoder = real_encoder();
1162 let quote = make_order_quote(990)
1163 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1164
1165 let fee = crate::ClientFeeParams::new(
1166 100,
1167 Bytes::from(make_address(0xBB).as_ref()),
1168 BigUint::from(0u64),
1169 1_893_456_000u64,
1170 Bytes::from(vec![0xAB; 65]),
1171 );
1172 let encoding_options = EncodingOptions::new(0.01).with_client_fee_params(fee);
1173
1174 let result = encoder
1175 .encode(vec![quote], encoding_options)
1176 .await
1177 .unwrap();
1178
1179 assert!(result[0].transaction().is_some());
1180 let tx = result[0].transaction().unwrap();
1181 assert!(!tx.data().is_empty());
1182 assert!(tx.data().len() > 4);
1184 }
1185
1186 #[tokio::test]
1187 async fn test_encode_without_client_fee_produces_transaction() {
1188 let encoder = real_encoder();
1189 let quote = make_order_quote(990)
1190 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1191
1192 let encoding_options = EncodingOptions::new(0.01);
1193
1194 let result = encoder
1195 .encode(vec![quote], encoding_options)
1196 .await
1197 .unwrap();
1198
1199 assert!(result[0].transaction().is_some());
1200 }
1201
1202 async fn encode_with_fallback(fallback_amount_out: u64) -> OrderQuote {
1205 let encoder = real_encoder();
1206 let mut route = make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]);
1207 route.set_fallback_amount_out(BigUint::from(fallback_amount_out));
1208 let quote = make_order_quote(990).with_route(route);
1209
1210 encoder
1211 .encode(vec![quote], EncodingOptions::new(0.01))
1212 .await
1213 .expect("encode")
1214 .remove(0)
1215 }
1216
1217 #[tokio::test]
1220 async fn test_encode_drops_pamm_quote_when_fallback_misses_the_floor() {
1221 let quote = encode_with_fallback(500).await;
1222
1223 assert_eq!(quote.status(), QuoteStatus::NoRouteFound);
1224 assert!(quote.transaction().is_none());
1225 assert!(quote.fee_breakdown().is_none());
1226 }
1227
1228 #[tokio::test]
1231 async fn test_encode_keeps_floor_when_fallback_clears_it() {
1232 let encoder = real_encoder();
1233 let venue_quote = make_order_quote(990)
1234 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1235 let venue = encoder
1236 .encode(vec![venue_quote], EncodingOptions::new(0.01))
1237 .await
1238 .expect("encode")
1239 .remove(0);
1240
1241 let with_fallback = encode_with_fallback(985).await;
1242
1243 let venue_fees = venue
1244 .fee_breakdown()
1245 .expect("venue fee breakdown");
1246 let fallback_fees = with_fallback
1247 .fee_breakdown()
1248 .expect("fallback fee breakdown");
1249
1250 assert_eq!(with_fallback.status(), QuoteStatus::Success);
1251 assert!(with_fallback.transaction().is_some());
1252 assert_eq!(fallback_fees.min_amount_received(), venue_fees.min_amount_received());
1253 assert_eq!(fallback_fees.router_fee(), venue_fees.router_fee());
1254 assert_eq!(fallback_fees.client_fee(), venue_fees.client_fee());
1255 assert_eq!(fallback_fees.max_slippage(), venue_fees.max_slippage());
1256 }
1257
1258 fn make_client_fee(bps: u16) -> crate::ClientFeeParams {
1261 crate::ClientFeeParams::new(
1262 bps,
1263 Bytes::from(make_address(0xBB).as_ref()),
1264 BigUint::from(0u64),
1265 1_893_456_000u64,
1266 Bytes::from(vec![]),
1267 )
1268 }
1269
1270 #[tokio::test]
1271 async fn test_encode_with_client_fee_returns_signature_offset() {
1272 let encoder = real_encoder();
1273 let quote = make_order_quote(990)
1274 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1275 let opts = EncodingOptions::new(0.01).with_client_fee_params(make_client_fee(100));
1276
1277 let result = encoder
1278 .encode(vec![quote], opts)
1279 .await
1280 .unwrap();
1281
1282 let tx = result[0].transaction().unwrap();
1283 tx.client_fee_signature_offset()
1284 .expect("client_fee_signature_offset must be present with client fee");
1285 }
1286
1287 #[tokio::test]
1288 async fn test_encode_without_client_fee_has_no_signature_offset() {
1289 let encoder = real_encoder();
1290 let quote = make_order_quote(990)
1291 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1292 let opts = EncodingOptions::new(0.01);
1293
1294 let result = encoder
1295 .encode(vec![quote], opts)
1296 .await
1297 .unwrap();
1298
1299 let tx = result[0].transaction().unwrap();
1300 assert!(tx
1301 .client_fee_signature_offset()
1302 .is_none());
1303 }
1304
1305 #[tokio::test]
1306 async fn test_signature_offset_allows_patching() {
1307 let encoder = real_encoder();
1308 let real_sig = vec![0xFF; 65];
1309 let quote = make_order_quote(990)
1310 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1311 let opts = EncodingOptions::new(0.01).with_client_fee_params(make_client_fee(100));
1312
1313 let result = encoder
1314 .encode(vec![quote], opts)
1315 .await
1316 .unwrap();
1317
1318 let tx = result[0].transaction().unwrap();
1319 let offset = tx
1320 .client_fee_signature_offset()
1321 .unwrap();
1322
1323 let mut calldata = tx.data().to_vec();
1324 calldata[offset..offset + 65].copy_from_slice(&real_sig);
1325 assert_eq!(&calldata[offset..offset + 65], &real_sig[..]);
1326 }
1327
1328 #[tokio::test]
1331 async fn test_encode_appends_calldata_watermark() {
1332 let encoder = real_encoder().with_calldata_watermark("fynd");
1333 let quote = make_order_quote(990)
1334 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1335
1336 let result = encoder
1337 .encode(vec![quote], EncodingOptions::new(0.01))
1338 .await
1339 .unwrap();
1340
1341 let tx = result[0].transaction().unwrap();
1342 assert!(
1343 tx.data().ends_with(b"fynd"),
1344 "calldata must end with the watermark bytes, got suffix {:?}",
1345 &tx.data()[tx.data().len().saturating_sub(4)..]
1346 );
1347 }
1348
1349 #[tokio::test]
1350 async fn test_encode_without_watermark_leaves_calldata_unchanged() {
1351 let make_quote = || {
1352 make_order_quote(990)
1353 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]))
1354 };
1355
1356 let plain = real_encoder()
1357 .encode(vec![make_quote()], EncodingOptions::new(0.01))
1358 .await
1359 .unwrap();
1360 let watermarked = real_encoder()
1361 .with_calldata_watermark("fynd")
1362 .encode(vec![make_quote()], EncodingOptions::new(0.01))
1363 .await
1364 .unwrap();
1365
1366 let plain_data = plain[0].transaction().unwrap().data();
1367 let watermarked_data = watermarked[0]
1368 .transaction()
1369 .unwrap()
1370 .data();
1371 assert_eq!(*plain_data, watermarked_data[..watermarked_data.len() - 4]);
1373 }
1374
1375 #[tokio::test]
1376 async fn test_watermarked_calldata_still_decodes() {
1377 let encoder = real_encoder().with_calldata_watermark("fynd");
1378 let quote = make_order_quote(1_000_000_000)
1379 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1380 let amount_in = quote.amount_in().clone();
1381 let opts = EncodingOptions::new(0.01).with_client_fee_params(make_client_fee(100));
1382
1383 let result = encoder
1384 .encode(vec![quote], opts)
1385 .await
1386 .unwrap();
1387
1388 let tx = result[0].transaction().unwrap();
1389 let (encoded_amount_in, _, _, _, _, _, _, _) =
1392 <SingleSwapCalldata as SolValue>::abi_decode_params(&tx.data()[4..tx.data().len() - 4])
1393 .unwrap();
1394 assert_eq!(encoded_amount_in, biguint_to_u256(&amount_in));
1395 }
1396
1397 #[tokio::test]
1398 async fn test_signature_offset_unaffected_by_watermark() {
1399 let encoder = real_encoder().with_calldata_watermark("fynd");
1400 let real_sig = vec![0xFF; 65];
1401 let quote = make_order_quote(990)
1402 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1403 let opts = EncodingOptions::new(0.01).with_client_fee_params(make_client_fee(100));
1404
1405 let result = encoder
1406 .encode(vec![quote], opts)
1407 .await
1408 .unwrap();
1409
1410 let tx = result[0].transaction().unwrap();
1411 let offset = tx
1412 .client_fee_signature_offset()
1413 .unwrap();
1414
1415 let mut calldata = tx.data().to_vec();
1416 calldata[offset..offset + 65].copy_from_slice(&real_sig);
1417 assert_eq!(&calldata[offset..offset + 65], &real_sig[..]);
1418 assert!(calldata.ends_with(b"fynd"));
1419 }
1420
1421 const FEE_SCALE: u64 = 100_000_000;
1425
1426 #[test]
1427 fn test_calculate_fee_breakdown() {
1428 let rates = FeeRates::new(100_000, 20_000_000, FEE_SCALE);
1430
1431 let breakdown =
1432 Encoder::calculate_fee_breakdown(&BigUint::from(1_000_000u64), 100, 0.0, rates)
1433 .unwrap();
1434
1435 assert_eq!(*breakdown.client_fee(), BigUint::from(8_000u64));
1438 assert_eq!(*breakdown.router_fee(), BigUint::from(3_000u64));
1439 assert_eq!(*breakdown.min_amount_received(), BigUint::from(989_000u64));
1440 }
1441
1442 #[test]
1443 fn test_calculate_fee_breakdown_zero_fees() {
1444 let rates = FeeRates::new(0, 0, FEE_SCALE);
1445
1446 let breakdown =
1447 Encoder::calculate_fee_breakdown(&BigUint::from(1_000_000u64), 0, 0.0, rates).unwrap();
1448
1449 assert_eq!(*breakdown.client_fee(), BigUint::ZERO);
1450 assert_eq!(*breakdown.router_fee(), BigUint::ZERO);
1451 assert_eq!(*breakdown.min_amount_received(), BigUint::from(1_000_000u64));
1452 }
1453
1454 #[test]
1455 fn test_calculate_fee_breakdown_fee_too_high() {
1456 let rates = FeeRates::new(1, 0, FEE_SCALE);
1458
1459 let result =
1460 Encoder::calculate_fee_breakdown(&BigUint::from(1_000_000u64), 10_000, 0.0, rates);
1461
1462 assert!(result.is_err());
1463 }
1464
1465 #[tokio::test]
1466 async fn test_encode_uses_custom_fees_for_client_fee_receiver() {
1467 let encoder = real_encoder();
1468 let custom =
1470 FxHashMap::from_iter([(Bytes::from(make_address(0xBB).as_ref()), (0u32, 0u32))]);
1471 encoder
1472 .router_fees()
1473 .set(RouterFees::new(FEE_SCALE, 1_000_000, 20_000_000, custom));
1474 let quote = make_order_quote(1_000_000_000)
1475 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1476 let opts = EncodingOptions::new(0.0).with_client_fee_params(make_client_fee(100));
1477
1478 let result = encoder
1479 .encode(vec![quote], opts)
1480 .await
1481 .unwrap();
1482
1483 let breakdown = result[0].fee_breakdown().unwrap();
1484 assert_eq!(*breakdown.router_fee(), BigUint::ZERO);
1485 assert_eq!(*breakdown.client_fee(), BigUint::from(10_000_000u64));
1487 }
1488
1489 #[tokio::test]
1490 async fn test_encode_falls_back_to_sender() {
1491 let encoder = real_encoder();
1492 let custom = FxHashMap::from_iter([(
1495 Bytes::from(make_address(0xAA).as_ref()),
1496 (0u32, 20_000_000u32),
1497 )]);
1498 encoder
1499 .router_fees()
1500 .set(RouterFees::new(FEE_SCALE, 1_000_000, 20_000_000, custom));
1501 let quote = make_order_quote(1_000_000_000)
1502 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1503
1504 let result = encoder
1505 .encode(vec![quote], EncodingOptions::new(0.0))
1506 .await
1507 .unwrap();
1508
1509 let breakdown = result[0].fee_breakdown().unwrap();
1510 assert_eq!(*breakdown.router_fee(), BigUint::ZERO);
1511 }
1512
1513 #[tokio::test]
1514 async fn test_encode_unknown_client() {
1515 let encoder = real_encoder();
1516 encoder
1517 .router_fees()
1518 .set(RouterFees::new(
1519 FEE_SCALE,
1520 1_000_000,
1521 20_000_000,
1522 rustc_hash::FxHashMap::default(),
1523 ));
1524 let quote = make_order_quote(1_000_000_000)
1525 .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1526
1527 let result = encoder
1528 .encode(vec![quote], EncodingOptions::new(0.0))
1529 .await
1530 .unwrap();
1531
1532 let breakdown = result[0].fee_breakdown().unwrap();
1533 assert_eq!(*breakdown.router_fee(), BigUint::from(10_000_000u64));
1535 }
1536}