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