Skip to main content

fynd_core/encoding/
encoder.rs

1use std::sync::Arc;
2
3use alloy::{
4    primitives::{aliases::U48, Address, Keccak256, U160, U256},
5    sol_types::SolValue,
6};
7use num_bigint::BigUint;
8use tycho_execution::encoding::{
9    errors::EncodingError,
10    evm::{
11        approvals::permit2::{PermitDetails as SolPermitDetails, PermitSingle},
12        encoder_builders::TychoRouterEncoderBuilder,
13        get_router_address,
14        swap_encoder::swap_encoder_registry::SwapEncoderRegistry,
15        utils::{biguint_to_u256, bytes_to_address},
16    },
17    models::{EncodedSolution, Solution, Swap},
18    tycho_encoder::TychoEncoder,
19};
20use tycho_simulation::tycho_common::{models::Chain, Bytes};
21
22use crate::{EncodingOptions, FeeBreakdown, OrderQuote, QuoteStatus, SolveError, Transaction};
23
24/// Canonical Permit2 contract address — identical on all EVM chains.
25pub const PERMIT2_ADDRESS: &str = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
26
27/// Router fee on swap output amount: 10 basis points (0.1%).
28const ROUTER_FEE_ON_OUTPUT_BPS: u64 = 10;
29/// Router's share of the client fee: 2000 basis points (20%).
30const ROUTER_FEE_ON_CLIENT_FEE_BPS: u64 = 2000;
31
32/// Encodes solution into tycho compatible transactions.
33///
34/// # Fields
35/// * `tycho_encoder` - Encoder created using the configured chain for encoding solutions into tycho
36///   compatible transactions
37/// * `chain` - Chain to be used.
38/// * `router_address` - Address of the Tycho Router contract on this chain.
39pub struct Encoder {
40    tycho_encoder: Box<dyn TychoEncoder>,
41    chain: Chain,
42    router_address: Bytes,
43    /// Dedicated multi-threaded runtime so that swap encoders using
44    /// `block_in_place` (e.g. Bebop RFQ) work even when the caller
45    /// runs on a current-thread runtime (actix-web workers).
46    encoding_rt: Option<Arc<tokio::runtime::Runtime>>,
47}
48
49impl Drop for Encoder {
50    fn drop(&mut self) {
51        // Take ownership so the field auto-drop is a no-op.
52        // If inside an async context, move shutdown to a background
53        // thread to avoid the "cannot drop a runtime" panic.
54        if let Some(rt) = self.encoding_rt.take() {
55            if tokio::runtime::Handle::try_current().is_ok() {
56                std::thread::spawn(move || drop(rt));
57            }
58        }
59    }
60}
61
62impl TryFrom<&OrderQuote> for Solution {
63    type Error = SolveError;
64
65    fn try_from(quote: &OrderQuote) -> Result<Self, Self::Error> {
66        if quote.status() != QuoteStatus::Success {
67            return Err(SolveError::FailedEncoding(format!(
68                "cannot convert quote with status {:?} to Solution",
69                quote.status()
70            )));
71        }
72
73        let route = quote.route().ok_or_else(|| {
74            SolveError::FailedEncoding("successful quote must have a route".to_string())
75        })?;
76
77        let token_in = route
78            .input_token()
79            .ok_or_else(|| SolveError::FailedEncoding("route has no input token".to_string()))?;
80        let token_out = route
81            .output_token()
82            .ok_or_else(|| SolveError::FailedEncoding("route has no output token".to_string()))?;
83
84        let swaps = route
85            .swaps()
86            .iter()
87            .map(|s| {
88                Swap::new(
89                    s.protocol_component().clone(),
90                    s.token_in().clone(),
91                    s.token_out().clone(),
92                )
93                .with_split(*s.split())
94                .with_protocol_state(Arc::from(s.protocol_state().clone_box()))
95                .with_estimated_amount_in(s.amount_in().clone())
96            })
97            .collect();
98
99        Ok(Solution::new(
100            quote.sender().clone(),
101            quote.receiver().clone(),
102            Bytes::from(token_in.as_ref()),
103            Bytes::from(token_out.as_ref()),
104            quote.amount_in().clone(),
105            quote.amount_out().clone(),
106            swaps,
107        ))
108    }
109}
110
111impl Encoder {
112    /// Creates a new `Encoder` for the given chain.
113    ///
114    /// # Arguments
115    /// * `chain` - Chain to encode solutions for.
116    /// * `swap_encoder_registry` - Registry of swap encoders for supported protocols.
117    ///
118    /// # Returns
119    /// A new `Encoder` configured with `TransferFrom` user transfer type.
120    pub fn new(
121        chain: Chain,
122        swap_encoder_registry: SwapEncoderRegistry,
123    ) -> Result<Self, SolveError> {
124        let router_address = get_router_address(&chain)
125            .map_err(|e| SolveError::FailedEncoding(e.to_string()))?
126            .clone();
127        let encoding_rt = tokio::runtime::Builder::new_multi_thread()
128            .worker_threads(2)
129            .enable_all()
130            .build()
131            .map_err(|e| {
132                SolveError::FailedEncoding(format!("failed to create encoding runtime: {e}"))
133            })?;
134        Ok(Self {
135            tycho_encoder: TychoRouterEncoderBuilder::new()
136                .chain(chain)
137                .swap_encoder_registry(swap_encoder_registry)
138                .build()?,
139            chain,
140            router_address,
141            encoding_rt: Some(Arc::new(encoding_rt)),
142        })
143    }
144
145    /// Returns the Tycho Router contract address for this chain.
146    pub fn router_address(&self) -> &Bytes {
147        &self.router_address
148    }
149
150    /// Encodes order solutions for execution.
151    ///
152    /// # Arguments
153    /// * `solutions` - Array containing order solutions.
154    /// * `encoding_options` - Additional context needed for encoding.
155    ///
156    /// # Returns
157    /// Input order solutions with the encoded transaction added to each successful solution.
158    pub async fn encode(
159        &self,
160        mut quotes: Vec<OrderQuote>,
161        encoding_options: EncodingOptions,
162    ) -> Result<Vec<OrderQuote>, SolveError> {
163        let slippage = encoding_options.slippage();
164        if slippage == 0.0 {
165            tracing::warn!("slippage is 0, transaction will likely revert");
166        } else if slippage > 0.5 {
167            tracing::warn!(slippage, "slippage exceeds 50%, possible misconfiguration");
168        }
169
170        let mut to_encode: Vec<(usize, Solution)> = Vec::new();
171
172        for (i, quote) in quotes.iter().enumerate() {
173            if quote.status() != QuoteStatus::Success {
174                continue;
175            }
176
177            to_encode.push((
178                i,
179                Solution::try_from(quote)?
180                    .with_user_transfer_type(encoding_options.transfer_type().clone()),
181            ));
182        }
183
184        let solutions: Vec<Solution> = to_encode
185            .iter()
186            .map(|(_, s)| s.clone())
187            .collect();
188        let rt = self
189            .encoding_rt
190            .as_ref()
191            .ok_or_else(|| SolveError::FailedEncoding("encoding runtime was dropped".into()))?;
192        let encoded_solutions = std::thread::scope(|scope| {
193            scope
194                .spawn(|| {
195                    rt.block_on(async {
196                        self.tycho_encoder
197                            .encode_solutions(solutions)
198                    })
199                })
200                .join()
201                .expect("encoding thread panicked")
202        })?;
203
204        for (encoded_solution, (idx, solution)) in encoded_solutions
205            .into_iter()
206            .zip(to_encode)
207        {
208            let (transaction, fee_breakdown) =
209                self.encode_tycho_router_call(encoded_solution, &solution, &encoding_options)?;
210            quotes[idx].set_transaction(transaction);
211            quotes[idx].set_fee_breakdown(fee_breakdown);
212        }
213
214        Ok(quotes)
215    }
216
217    /// Encodes a call using one of the router's swap methods.
218    ///
219    /// Selects the appropriate router function based on the function signature in
220    /// `encoded_solution` (single/sequential/split, with optional Permit2 or Vault variants),
221    /// prepends the 4-byte selector, and returns a `Transaction` ready for submission.
222    ///
223    /// Fee calculation mirrors the on-chain `FeeCalculator.calculateFee` using identical
224    /// integer arithmetic so `min_amount_out` passes the router's post-fee check.
225    fn encode_tycho_router_call(
226        &self,
227        encoded_solution: EncodedSolution,
228        solution: &Solution,
229        encoding_options: &EncodingOptions,
230    ) -> Result<(Transaction, FeeBreakdown), EncodingError> {
231        let amount_in = biguint_to_u256(solution.amount_in());
232        let swap_output = solution.min_amount_out();
233        let fee_breakdown = Self::calculate_fee_breakdown(
234            swap_output,
235            encoding_options
236                .client_fee_params()
237                .map_or(0, |f| f.bps()),
238            encoding_options.slippage(),
239        );
240        let min_amount_out = biguint_to_u256(fee_breakdown.min_amount_received());
241        let token_in = bytes_to_address(solution.token_in())?;
242        let token_out = bytes_to_address(solution.token_out())?;
243        let receiver = bytes_to_address(solution.receiver())?;
244
245        let (permit, permit2_sig) = if let Some(p) = encoding_options.permit() {
246            let d = p.details();
247            let permit = Some(PermitSingle {
248                details: SolPermitDetails {
249                    token: bytes_to_address(d.token())?,
250                    amount: U160::from(biguint_to_u256(d.amount())),
251                    expiration: U48::from(biguint_to_u256(d.expiration())),
252                    nonce: U48::from(biguint_to_u256(d.nonce())),
253                },
254                spender: bytes_to_address(p.spender())?,
255                sigDeadline: biguint_to_u256(p.sig_deadline()),
256            });
257            let sig = encoding_options
258                .permit2_signature()
259                .ok_or_else(|| {
260                    EncodingError::FatalError("Signature must be provided for permit2".to_string())
261                })?
262                .to_vec();
263            (permit, sig)
264        } else {
265            (None, vec![])
266        };
267
268        let client_fee_params = if let Some(fee) = encoding_options.client_fee_params() {
269            (
270                fee.bps(),
271                bytes_to_address(fee.receiver())?,
272                biguint_to_u256(fee.max_contribution()),
273                U256::from(fee.deadline()),
274                fee.signature().to_vec(),
275            )
276        } else {
277            (0u16, Address::ZERO, U256::ZERO, U256::MAX, vec![])
278        };
279
280        let fn_sig = encoded_solution.function_signature();
281        let swaps = encoded_solution.swaps();
282
283        let method_calldata = if fn_sig.contains("Permit2") {
284            let permit = permit.ok_or(EncodingError::FatalError(
285                "permit2 object must be set to use permit2".to_string(),
286            ))?;
287            if fn_sig.contains("splitSwap") {
288                (
289                    amount_in,
290                    token_in,
291                    token_out,
292                    min_amount_out,
293                    U256::from(encoded_solution.n_tokens()),
294                    receiver,
295                    client_fee_params,
296                    permit,
297                    permit2_sig,
298                    swaps,
299                )
300                    .abi_encode()
301            } else {
302                (
303                    amount_in,
304                    token_in,
305                    token_out,
306                    min_amount_out,
307                    receiver,
308                    client_fee_params,
309                    permit,
310                    permit2_sig,
311                    swaps,
312                )
313                    .abi_encode()
314            }
315        } else if fn_sig.contains("splitSwap") {
316            (
317                amount_in,
318                token_in,
319                token_out,
320                min_amount_out,
321                U256::from(encoded_solution.n_tokens()),
322                receiver,
323                client_fee_params,
324                swaps,
325            )
326                .abi_encode()
327        } else if fn_sig.contains("singleSwap") || fn_sig.contains("sequentialSwap") {
328            (amount_in, token_in, token_out, min_amount_out, receiver, client_fee_params, swaps)
329                .abi_encode()
330        } else {
331            return Err(EncodingError::FatalError(format!(
332                "unsupported function signature for Tycho router: {fn_sig}"
333            )));
334        };
335
336        let native_address = &self.chain.native_token().address;
337        let contract_interaction =
338            Self::encode_input(encoded_solution.function_signature(), method_calldata);
339        let value = if *solution.token_in() == *native_address {
340            solution.amount_in().clone()
341        } else {
342            BigUint::ZERO
343        };
344        let transaction = Transaction::new(
345            encoded_solution
346                .interacting_with()
347                .clone(),
348            value,
349            contract_interaction,
350        );
351        Ok((transaction, fee_breakdown))
352    }
353
354    /// Prepends the 4-byte Keccak selector for `selector` to the ABI-encoded args.
355    fn encode_input(selector: &str, mut encoded_args: Vec<u8>) -> Vec<u8> {
356        let mut hasher = Keccak256::new();
357        hasher.update(selector.as_bytes());
358        let selector_bytes = &hasher.finalize()[..4];
359        let mut call_data = selector_bytes.to_vec();
360        // Remove extra prefix if present (32 bytes for dynamic data)
361        // Alloy encoding is including a prefix for dynamic data indicating the offset or length
362        // but at this point we don't want that
363        if encoded_args.len() > 32 &&
364            encoded_args[..32] ==
365                [0u8; 31]
366                    .into_iter()
367                    .chain([32].to_vec())
368                    .collect::<Vec<u8>>()
369        {
370            encoded_args = encoded_args[32..].to_vec();
371        }
372        call_data.extend(encoded_args);
373        call_data
374    }
375
376    /// Mirrors the on-chain `FeeCalculator.calculateFee` using identical integer arithmetic.
377    ///
378    /// Given the raw swap output, client fee in bps, and slippage tolerance, computes
379    /// the exact fee amounts and the minimum amount the user will receive.
380    fn calculate_fee_breakdown(
381        swap_output: &BigUint,
382        client_fee_bps: u16,
383        slippage: f64,
384    ) -> FeeBreakdown {
385        let client_bps = client_fee_bps as u64;
386
387        let mut router_fee_on_client = BigUint::ZERO;
388        let mut client_portion = BigUint::ZERO;
389
390        if client_bps > 0 {
391            let fee_numerator = swap_output * client_bps;
392            let total_client_fee = &fee_numerator / 10_000u64;
393
394            router_fee_on_client = &fee_numerator * ROUTER_FEE_ON_CLIENT_FEE_BPS / 100_000_000u64;
395
396            client_portion = total_client_fee - &router_fee_on_client;
397        }
398
399        let router_fee_on_output = swap_output * ROUTER_FEE_ON_OUTPUT_BPS / 10_000u64;
400        let total_router_fee = router_fee_on_client + router_fee_on_output;
401
402        let amount_after_fees = swap_output - &client_portion - &total_router_fee;
403
404        let precision = BigUint::from(1_000_000u64);
405        let slippage_amount =
406            &amount_after_fees * BigUint::from((slippage * 1_000_000.0) as u64) / &precision;
407
408        let min_amount_received = &amount_after_fees - &slippage_amount;
409
410        FeeBreakdown::new(total_router_fee, client_portion, slippage_amount, min_amount_received)
411    }
412}
413
414impl From<EncodingError> for SolveError {
415    fn from(err: EncodingError) -> Self {
416        SolveError::FailedEncoding(err.to_string())
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use num_bigint::BigUint;
423    use tycho_execution::encoding::{
424        errors::EncodingError,
425        models::{EncodedSolution, Solution},
426        tycho_encoder::TychoEncoder,
427    };
428    use tycho_simulation::tycho_core::{
429        models::{token::Token, Address, Chain as SimChain},
430        Bytes,
431    };
432
433    use super::*;
434    use crate::{
435        algorithm::test_utils::{component, MockProtocolSim},
436        BlockInfo, OrderQuote, QuoteStatus,
437    };
438
439    fn make_route_swap_addrs(token_in: Address, token_out: Address) -> crate::types::Swap {
440        let make_token = |addr: Address| Token {
441            address: addr,
442            symbol: "T".to_string(),
443            decimals: 18,
444            tax: Default::default(),
445            gas: vec![],
446            chain: SimChain::Ethereum,
447            quality: 100,
448        };
449        let tin = make_token(token_in.clone());
450        let tout = make_token(token_out.clone());
451        // Component ID must be a valid address for the USV2 swap encoder
452        let pool_addr = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
453        crate::types::Swap::new(
454            pool_addr.to_string(),
455            "uniswap_v2".to_string(),
456            token_in,
457            token_out,
458            BigUint::from(1000u64),
459            BigUint::from(990u64),
460            BigUint::from(50_000u64),
461            component(pool_addr, &[tin, tout]),
462            Box::new(MockProtocolSim::default()),
463        )
464    }
465
466    fn make_address(byte: u8) -> Address {
467        Address::from([byte; 20])
468    }
469
470    fn make_order_quote() -> OrderQuote {
471        OrderQuote::new(
472            "test-order".to_string(),
473            QuoteStatus::Success,
474            BigUint::from(1000u64),
475            BigUint::from(990u64),
476            BigUint::from(100_000u64),
477            BigUint::from(990u64),
478            BlockInfo::new(1, "0x123".to_string(), 1000),
479            "test".to_string(),
480            Bytes::from(make_address(0xAA).as_ref()),
481            Bytes::from(make_address(0xAA).as_ref()),
482        )
483    }
484
485    struct MockTychoEncoder;
486
487    impl TychoEncoder for MockTychoEncoder {
488        fn encode_solutions(
489            &self,
490            _solutions: Vec<Solution>,
491        ) -> Result<Vec<EncodedSolution>, EncodingError> {
492            Ok(vec![])
493        }
494
495        fn validate_solution(&self, _solution: &Solution) -> Result<(), EncodingError> {
496            Ok(())
497        }
498    }
499
500    fn mock_encoder(chain: Chain) -> Encoder {
501        Encoder {
502            tycho_encoder: Box::new(MockTychoEncoder),
503            chain,
504            router_address: Bytes::from([0u8; 20].as_ref()),
505            encoding_rt: Some(Arc::new(
506                tokio::runtime::Builder::new_multi_thread()
507                    .worker_threads(1)
508                    .enable_all()
509                    .build()
510                    .unwrap(),
511            )),
512        }
513    }
514
515    #[test]
516    fn test_encoder_new_fails_on_unsupported_chain() {
517        // Arbitrum has no entry in ROUTER_ADDRESSES_JSON.
518        // Build a registry for Ethereum (which is valid) but pass Arbitrum to Encoder::new —
519        // the router address lookup must fail before the encoder builder is invoked.
520        let registry =
521            tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry::new(Chain::Ethereum)
522                .add_default_encoders(None)
523                .expect("registry should build for Ethereum");
524        let result = Encoder::new(Chain::Arbitrum, registry);
525        assert!(result.is_err(), "expected Err for chain without router address, got Ok");
526    }
527
528    #[test]
529    fn test_try_from_without_route_errors() {
530        let quote = make_order_quote();
531
532        let result = Solution::try_from(&quote);
533
534        assert!(result.is_err());
535    }
536
537    #[test]
538    fn test_try_from_non_success_errors() {
539        let quote = OrderQuote::new(
540            "test-order".to_string(),
541            QuoteStatus::NoRouteFound,
542            BigUint::from(1000u64),
543            BigUint::from(990u64),
544            BigUint::from(100_000u64),
545            BigUint::from(990u64),
546            BlockInfo::new(1, "0x123".to_string(), 1000),
547            "test".to_string(),
548            Bytes::from(make_address(0xAA).as_ref()),
549            Bytes::from(make_address(0xAA).as_ref()),
550        );
551
552        let result = Solution::try_from(&quote);
553
554        assert!(result.is_err());
555    }
556
557    #[test]
558    fn test_try_from_maps_tokens_and_amounts() {
559        let quote =
560            make_order_quote().with_route(crate::types::Route::new(vec![make_route_swap_addrs(
561                make_address(0x01),
562                make_address(0x02),
563            )]));
564
565        let solution = Solution::try_from(&quote).unwrap();
566
567        assert_eq!(*solution.token_in(), Bytes::from(make_address(0x01).as_ref()));
568        assert_eq!(*solution.token_out(), Bytes::from(make_address(0x02).as_ref()));
569        assert_eq!(*solution.amount_in(), *quote.amount_in());
570        assert_eq!(*solution.min_amount_out(), *quote.amount_out());
571        assert_eq!(solution.swaps().len(), 1);
572    }
573
574    #[test]
575    fn test_try_from_multi_hop_uses_boundary_swap_tokens() {
576        let quote = make_order_quote().with_route(crate::types::Route::new(vec![
577            make_route_swap_addrs(make_address(0x01), make_address(0x02)),
578            make_route_swap_addrs(make_address(0x02), make_address(0x03)),
579        ]));
580
581        let solution = Solution::try_from(&quote).unwrap();
582
583        assert_eq!(*solution.token_in(), Bytes::from(make_address(0x01).as_ref()));
584        assert_eq!(*solution.token_out(), Bytes::from(make_address(0x03).as_ref()));
585        assert_eq!(solution.swaps().len(), 2);
586    }
587
588    #[tokio::test]
589    async fn test_encode_skips_non_successful_solutions() {
590        let encoder = mock_encoder(Chain::Ethereum);
591        let quote = OrderQuote::new(
592            "test-order".to_string(),
593            QuoteStatus::NoRouteFound,
594            BigUint::from(1000u64),
595            BigUint::from(990u64),
596            BigUint::from(100_000u64),
597            BigUint::from(990u64),
598            BlockInfo::new(1, "0x123".to_string(), 1000),
599            "test".to_string(),
600            Bytes::from(make_address(0xAA).as_ref()),
601            Bytes::from(make_address(0xAA).as_ref()),
602        );
603
604        let encoding_options = EncodingOptions::new(0.01);
605
606        let result = encoder
607            .encode(vec![quote], encoding_options)
608            .await
609            .unwrap();
610
611        assert!(result[0].transaction().is_none());
612    }
613
614    fn real_encoder() -> Encoder {
615        let registry = SwapEncoderRegistry::new(Chain::Ethereum)
616            .add_default_encoders(None)
617            .unwrap();
618        Encoder::new(Chain::Ethereum, registry).unwrap()
619    }
620
621    #[tokio::test]
622    async fn test_encode_sets_transaction_on_successful_solution() {
623        let encoder = real_encoder();
624        let quote =
625            make_order_quote().with_route(crate::types::Route::new(vec![make_route_swap_addrs(
626                make_address(0x01),
627                make_address(0x02),
628            )]));
629
630        let encoding_options = EncodingOptions::new(0.01);
631
632        let result = encoder
633            .encode(vec![quote], encoding_options)
634            .await
635            .unwrap();
636
637        assert!(result[0].transaction().is_some());
638        let tx = result[0].transaction().unwrap();
639        assert!(!tx.data().is_empty());
640        // Data starts with a 4-byte function selector
641        assert!(tx.data().len() > 4);
642    }
643
644    #[tokio::test]
645    async fn test_encode_with_client_fee_params() {
646        let encoder = real_encoder();
647        let quote =
648            make_order_quote().with_route(crate::types::Route::new(vec![make_route_swap_addrs(
649                make_address(0x01),
650                make_address(0x02),
651            )]));
652
653        let fee = crate::ClientFeeParams::new(
654            100,
655            Bytes::from(make_address(0xBB).as_ref()),
656            BigUint::from(0u64),
657            1_893_456_000u64,
658            Bytes::from(vec![0xAB; 65]),
659        );
660        let encoding_options = EncodingOptions::new(0.01).with_client_fee_params(fee);
661
662        let result = encoder
663            .encode(vec![quote], encoding_options)
664            .await
665            .unwrap();
666
667        assert!(result[0].transaction().is_some());
668        let tx = result[0].transaction().unwrap();
669        assert!(!tx.data().is_empty());
670        // Calldata with fee params should be longer than without
671        assert!(tx.data().len() > 4);
672    }
673
674    #[tokio::test]
675    async fn test_encode_without_client_fee_produces_transaction() {
676        let encoder = real_encoder();
677        let quote =
678            make_order_quote().with_route(crate::types::Route::new(vec![make_route_swap_addrs(
679                make_address(0x01),
680                make_address(0x02),
681            )]));
682
683        let encoding_options = EncodingOptions::new(0.01);
684
685        let result = encoder
686            .encode(vec![quote], encoding_options)
687            .await
688            .unwrap();
689
690        assert!(result[0].transaction().is_some());
691    }
692}