Skip to main content

fynd_core/encoding/
encoder.rs

1use std::sync::Arc;
2
3use alloy::{
4    primitives::{aliases::U48, keccak256, 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        ROUTER_ETH_ADDRESS,
17    },
18    models::{EncodedSolution, Solution, Swap},
19    tycho_encoder::TychoEncoder,
20};
21use tycho_simulation::tycho_common::{models::Chain, Bytes};
22
23use crate::{
24    encoding::{
25        exclusive_swap::ExclusiveSwapSigner,
26        router_fees::{FeeRates, RouterFees, SharedRouterFees},
27    },
28    EncodingOptions, FeeBreakdown, OrderQuote, QuoteStatus, SolveError, Transaction,
29};
30
31/// Canonical Permit2 contract address — identical on all EVM chains.
32pub const PERMIT2_ADDRESS: &str = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
33
34/// Encodes solution into tycho compatible transactions.
35///
36/// # Fields
37/// * `tycho_encoder` - Encoder created using the configured chain for encoding solutions into tycho
38///   compatible transactions. `None` when the encoder is disabled (router-less / quote-only chain).
39/// * `chain` - Chain to be used.
40/// * `router_address` - Address of the Tycho Router contract on this chain, or `None` if Tycho has
41///   no router deployed there — encoding is then unavailable and `encode()` fails clearly.
42/// * `router_fees` - Router fee configuration, refreshed from chain by a background fetcher.
43pub struct Encoder {
44    tycho_encoder: Option<Box<dyn TychoEncoder>>,
45    chain: Chain,
46    router_address: Option<Bytes>,
47    router_fees: SharedRouterFees,
48    /// Signs exclusive legs. `None` disables signing (no controller key configured).
49    exclusive_swap_signer: Option<ExclusiveSwapSigner>,
50}
51
52impl TryFrom<&OrderQuote> for Solution {
53    type Error = SolveError;
54
55    fn try_from(quote: &OrderQuote) -> Result<Self, Self::Error> {
56        if quote.status() != QuoteStatus::Success {
57            return Err(SolveError::FailedEncoding(format!(
58                "cannot convert quote with status {:?} to Solution",
59                quote.status()
60            )));
61        }
62
63        let route = quote.route().ok_or_else(|| {
64            SolveError::FailedEncoding("successful quote must have a route".to_string())
65        })?;
66
67        let token_in = route
68            .input_token()
69            .ok_or_else(|| SolveError::FailedEncoding("route has no input token".to_string()))?;
70        let token_out = route
71            .output_token()
72            .ok_or_else(|| SolveError::FailedEncoding("route has no output token".to_string()))?;
73
74        let token_map = route.tokens();
75        let lookup_token = |addr: &Bytes| {
76            token_map
77                .get(addr)
78                .cloned()
79                .ok_or_else(|| {
80                    SolveError::FailedEncoding(format!(
81                        "token {addr:?} not found in route's token map; \
82                     algorithm must populate Route::with_tokens for every swap token"
83                    ))
84                })
85        };
86        let swaps = route
87            .swaps()
88            .iter()
89            .map(|s| {
90                let token_in = lookup_token(s.token_in())?;
91                let token_out = lookup_token(s.token_out())?;
92                Ok(Swap::new(
93                    s.protocol_component().clone(),
94                    token_in,
95                    token_out,
96                    s.gas_estimate().clone(),
97                )
98                .with_split(*s.split())
99                .with_protocol_state(Arc::from(s.protocol_state().clone_box()))
100                .with_estimated_amount_in(s.amount_in().clone()))
101            })
102            .collect::<Result<Vec<_>, SolveError>>()?;
103
104        Ok(Solution::new(
105            quote.sender().clone(),
106            quote.receiver().clone(),
107            Bytes::from(token_in.as_ref()),
108            Bytes::from(token_out.as_ref()),
109            quote.amount_in().clone(),
110            quote.amount_out().clone(),
111            swaps,
112        ))
113    }
114}
115
116impl Encoder {
117    /// Whether Tycho has a router deployment (and thus encoding support) for `chain`.
118    pub fn is_supported(chain: Chain) -> bool {
119        get_router_address(&chain).is_ok()
120    }
121
122    /// Creates a new `Encoder` for the given chain.
123    ///
124    /// # Arguments
125    /// * `chain` - Chain to encode solutions for.
126    /// * `swap_encoder_registry` - Registry of swap encoders for supported protocols.
127    ///
128    /// # Returns
129    /// A new `Encoder` configured with `TransferFrom` user transfer type. If `chain` has no Tycho
130    /// router deployment, the encoder is returned in a disabled state: it can still be used to
131    /// quote, but [`Self::encode`] will fail with [`SolveError::FailedEncoding`].
132    pub fn new(
133        chain: Chain,
134        swap_encoder_registry: SwapEncoderRegistry,
135    ) -> Result<Self, SolveError> {
136        let router_address = get_router_address(&chain).ok().cloned();
137        let tycho_encoder = router_address
138            .is_some()
139            .then(|| {
140                TychoRouterEncoderBuilder::new()
141                    .chain(chain)
142                    .swap_encoder_registry(swap_encoder_registry)
143                    .build()
144            })
145            .transpose()?;
146        let exclusive_swap_signer = ExclusiveSwapSigner::from_env(chain.id())?;
147        Ok(Self {
148            tycho_encoder,
149            chain,
150            router_address,
151            router_fees: SharedRouterFees::default(),
152            exclusive_swap_signer,
153        })
154    }
155
156    /// Overrides the exclusive-swap signer, replacing whatever was read from the environment.
157    #[must_use]
158    pub fn with_exclusive_swap_signer(mut self, signer: ExclusiveSwapSigner) -> Self {
159        self.exclusive_swap_signer = Some(signer);
160        self
161    }
162
163    /// Returns the Tycho Router contract address for this chain, or `None` if encoding is
164    /// unavailable because no router is deployed there.
165    pub fn router_address(&self) -> Option<&Bytes> {
166        self.router_address.as_ref()
167    }
168
169    /// Returns the shared router fee handle this encoder reads on every encode.
170    ///
171    /// Pass it to a [`RouterFeeFetcher`](crate::encoding::fee_fetcher::RouterFeeFetcher)
172    /// to keep the fees in sync with the on-chain FeeCalculator.
173    pub fn router_fees(&self) -> SharedRouterFees {
174        self.router_fees.clone()
175    }
176
177    /// Encodes order solutions for execution.
178    ///
179    /// # Arguments
180    /// * `solutions` - Array containing order solutions.
181    /// * `encoding_options` - Additional context needed for encoding.
182    ///
183    /// # Returns
184    /// Input order solutions with the encoded transaction added to each successful solution.
185    pub async fn encode(
186        &self,
187        mut quotes: Vec<OrderQuote>,
188        encoding_options: EncodingOptions,
189    ) -> Result<Vec<OrderQuote>, SolveError> {
190        let Some(tycho_encoder) = self.tycho_encoder.as_ref() else {
191            return Err(SolveError::EncodingUnavailable(format!(
192                "encoding is unavailable on chain '{}': no Tycho router is deployed. Fynd is \
193                 running quote-only; contact ops to deploy the router/executor contracts.",
194                self.chain
195            )));
196        };
197
198        let slippage = encoding_options.slippage();
199        if slippage == 0.0 {
200            tracing::warn!("slippage is 0, transaction will likely revert");
201        } else if slippage > 0.5 {
202            tracing::warn!(slippage, "slippage exceeds 50%, possible misconfiguration");
203        }
204
205        let mut to_encode: Vec<(usize, Solution)> = Vec::new();
206
207        for (i, quote) in quotes.iter().enumerate() {
208            if quote.status() != QuoteStatus::Success {
209                continue;
210            }
211
212            let solution = Solution::try_from(quote)?
213                .with_user_transfer_type(encoding_options.transfer_type().clone());
214            let solution = match &self.exclusive_swap_signer {
215                Some(signer) => Self::stamp_exclusive_swaps(solution, quote, signer)?,
216                None => {
217                    // Fail fast rather than emit on-chain-invalid unsigned calldata for an
218                    // exclusive leg: an exclusive route requires a signature.
219                    if has_exclusive_leg(quote) {
220                        return Err(SolveError::FailedEncoding(
221                            "quote routes through an exclusive pool but no signing key is \
222                             configured (set EXCLUSIVE_SWAP_CONTROLLER_KEY)"
223                                .to_string(),
224                        ));
225                    }
226                    solution
227                }
228            };
229            to_encode.push((i, solution));
230        }
231
232        let solutions: Vec<Solution> = to_encode
233            .iter()
234            .map(|(_, s)| s.clone())
235            .collect();
236        let encoded_solutions = tycho_encoder.encode_solutions(solutions)?;
237
238        let router_fees = self.router_fees.snapshot();
239        for (encoded_solution, (idx, solution)) in encoded_solutions
240            .into_iter()
241            .zip(to_encode)
242        {
243            quotes[idx].set_gas_estimate(encoded_solution.estimated_gas().clone());
244            let (transaction, fee_breakdown) = self.encode_tycho_router_call(
245                encoded_solution,
246                &solution,
247                &encoding_options,
248                &router_fees,
249            )?;
250            quotes[idx].set_transaction(transaction);
251            quotes[idx].set_fee_breakdown(fee_breakdown);
252        }
253
254        Ok(quotes)
255    }
256
257    /// Stamps controller-signed `user_data` onto each exclusive leg of `solution`.
258    ///
259    /// A leg is exclusive when its route swap carries a committed amount. The solution's swaps are
260    /// built 1:1 from the route's swaps, so they are matched by index. Returns `solution` unchanged
261    /// when no leg is exclusive.
262    fn stamp_exclusive_swaps(
263        solution: Solution,
264        quote: &OrderQuote,
265        signer: &ExclusiveSwapSigner,
266    ) -> Result<Solution, SolveError> {
267        let route = quote.route().ok_or_else(|| {
268            SolveError::FailedEncoding("successful quote must have a route".to_string())
269        })?;
270        let route_swaps = route.swaps();
271
272        // Nothing to sign unless a leg carries a committed amount; leave the solution untouched.
273        if !route_swaps
274            .iter()
275            .any(|s| s.committed_amount_out().is_some())
276        {
277            return Ok(solution);
278        }
279
280        // `route_swaps` carry `committed_amount_out` and the pool attributes; `solution.swaps()`
281        // are built 1:1 from them by `Solution::try_from` and are what the router executes.
282        // We read the committed amount from the route swap but stamp `user_data` onto the
283        // matching solution swap, matched by index via the zip below.
284        let swaps = solution
285            .swaps()
286            .iter()
287            .cloned()
288            .zip(route_swaps.iter())
289            // Only the exclusive leg (the route swap carrying a committed amount) gets signed
290            // `user_data`; every other solution swap passes through unchanged.
291            .map(|(solution_swap, route_swap)| {
292                if route_swap
293                    .committed_amount_out()
294                    .is_some()
295                {
296                    let user_data = signer.build_user_data(route_swap)?;
297                    Ok(solution_swap.with_user_data(user_data))
298                } else {
299                    Ok(solution_swap)
300                }
301            })
302            .collect::<Result<Vec<_>, SolveError>>()?;
303
304        Ok(solution.with_swaps(swaps))
305    }
306
307    /// Encodes a call using one of the router's swap methods.
308    ///
309    /// Selects the appropriate router function based on the function signature in
310    /// `encoded_solution` (single/sequential/split, with optional Permit2 or Vault variants),
311    /// prepends the 4-byte selector, and returns a `Transaction` ready for submission.
312    ///
313    /// Fee calculation mirrors the on-chain `FeeCalculator.calculateFee` using identical
314    /// integer arithmetic so `min_amount_out` passes the router's post-fee check.
315    fn encode_tycho_router_call(
316        &self,
317        encoded_solution: EncodedSolution,
318        solution: &Solution,
319        encoding_options: &EncodingOptions,
320        router_fees: &RouterFees,
321    ) -> Result<(Transaction, FeeBreakdown), EncodingError> {
322        let amount_in = biguint_to_u256(solution.amount_in());
323        let swap_output = solution.min_amount_out();
324        // Mirror FeeCalculator._resolveClient: custom router fees are looked up by the client
325        // fee receiver; without client fee params the contract falls back to tx.origin, for
326        // which the order sender is our best available proxy.
327        let fee_client = encoding_options
328            .client_fee_params()
329            .map_or_else(|| solution.sender(), |f| f.receiver());
330        let fee_breakdown = Self::calculate_fee_breakdown(
331            swap_output,
332            encoding_options
333                .client_fee_params()
334                .map_or(0, |f| f.bps()),
335            encoding_options.slippage(),
336            router_fees.fees_for(fee_client),
337        )?;
338        let min_amount_out = biguint_to_u256(fee_breakdown.min_amount_received());
339        let native_address = &self.chain.native_token().address;
340        let router_eth = Address::from_slice(ROUTER_ETH_ADDRESS.as_ref());
341        let to_router_address = |raw: Address| {
342            if raw.as_slice() == native_address.as_ref() {
343                router_eth
344            } else {
345                raw
346            }
347        };
348
349        let token_in = to_router_address(bytes_to_address(solution.token_in())?);
350        let token_out = to_router_address(bytes_to_address(solution.token_out())?);
351        let receiver = bytes_to_address(solution.receiver())?;
352
353        let (permit, permit2_sig) = if let Some(p) = encoding_options.permit() {
354            let d = p.details();
355            let permit = Some(PermitSingle {
356                details: SolPermitDetails {
357                    token: bytes_to_address(d.token())?,
358                    amount: U160::from(biguint_to_u256(d.amount())),
359                    expiration: U48::from(biguint_to_u256(d.expiration())),
360                    nonce: U48::from(biguint_to_u256(d.nonce())),
361                },
362                spender: bytes_to_address(p.spender())?,
363                sigDeadline: biguint_to_u256(p.sig_deadline()),
364            });
365            let sig = encoding_options
366                .permit2_signature()
367                .ok_or_else(|| {
368                    EncodingError::FatalError("Signature must be provided for permit2".to_string())
369                })?
370                .to_vec();
371            (permit, sig)
372        } else {
373            (None, vec![])
374        };
375
376        let client_fee_params = if let Some(fee) = encoding_options.client_fee_params() {
377            (
378                fee.bps(),
379                bytes_to_address(fee.receiver())?,
380                biguint_to_u256(fee.max_contribution()),
381                U256::from(fee.deadline()),
382                // Pad to 65 bytes so the ABI encoding always reserves room for
383                // the client to patch the real EIP-712 signature after signing.
384                {
385                    let mut sig = fee.signature().to_vec();
386                    sig.resize(65, 0);
387                    sig
388                },
389            )
390        } else {
391            (0u16, Address::ZERO, U256::ZERO, U256::MAX, vec![])
392        };
393
394        let fn_sig = encoded_solution.function_signature();
395        let swaps = encoded_solution.swaps();
396        let fee_breakdown = if encoding_options
397            .client_fee_params()
398            .is_some()
399        {
400            fee_breakdown.with_swaps_hash(keccak256(swaps).0)
401        } else {
402            fee_breakdown
403        };
404
405        let method_calldata = if fn_sig.contains("Permit2") {
406            let permit = permit.ok_or(EncodingError::FatalError(
407                "permit2 object must be set to use permit2".to_string(),
408            ))?;
409            if fn_sig.contains("splitSwap") {
410                (
411                    amount_in,
412                    token_in,
413                    token_out,
414                    min_amount_out,
415                    U256::from(encoded_solution.n_tokens()),
416                    receiver,
417                    client_fee_params,
418                    permit,
419                    permit2_sig,
420                    swaps,
421                )
422                    .abi_encode()
423            } else {
424                (
425                    amount_in,
426                    token_in,
427                    token_out,
428                    min_amount_out,
429                    receiver,
430                    client_fee_params,
431                    permit,
432                    permit2_sig,
433                    swaps,
434                )
435                    .abi_encode()
436            }
437        } else if fn_sig.contains("splitSwap") {
438            (
439                amount_in,
440                token_in,
441                token_out,
442                min_amount_out,
443                U256::from(encoded_solution.n_tokens()),
444                receiver,
445                client_fee_params,
446                swaps,
447            )
448                .abi_encode()
449        } else if fn_sig.contains("singleSwap") || fn_sig.contains("sequentialSwap") {
450            (amount_in, token_in, token_out, min_amount_out, receiver, client_fee_params, swaps)
451                .abi_encode()
452        } else {
453            return Err(EncodingError::FatalError(format!(
454                "unsupported function signature for Tycho router: {fn_sig}"
455            )));
456        };
457
458        let contract_interaction =
459            Self::encode_input(encoded_solution.function_signature(), method_calldata);
460
461        let value =
462            if token_in == router_eth { solution.amount_in().clone() } else { BigUint::ZERO };
463        let mut transaction = Transaction::new(
464            encoded_solution
465                .interacting_with()
466                .clone(),
467            value,
468            contract_interaction,
469        );
470        if encoding_options
471            .client_fee_params()
472            .is_some()
473        {
474            let offset = encoded_solution.client_fee_signature_offset();
475            transaction = transaction.with_client_fee_signature_offset(offset);
476        }
477        Ok((transaction, fee_breakdown))
478    }
479
480    /// Prepends the 4-byte Keccak selector for `selector` to the ABI-encoded args.
481    fn encode_input(selector: &str, mut encoded_args: Vec<u8>) -> Vec<u8> {
482        let mut hasher = Keccak256::new();
483        hasher.update(selector.as_bytes());
484        let selector_bytes = &hasher.finalize()[..4];
485        let mut call_data = selector_bytes.to_vec();
486        // Remove extra prefix if present (32 bytes for dynamic data)
487        // Alloy encoding is including a prefix for dynamic data indicating the offset or length
488        // but at this point we don't want that
489        if encoded_args.len() > 32 &&
490            encoded_args[..32] ==
491                [0u8; 31]
492                    .into_iter()
493                    .chain([32].to_vec())
494                    .collect::<Vec<u8>>()
495        {
496            encoded_args = encoded_args[32..].to_vec();
497        }
498        call_data.extend(encoded_args);
499        call_data
500    }
501
502    /// Mirrors the on-chain `FeeCalculator.calculateFee` using identical integer arithmetic.
503    ///
504    /// Given the raw swap output, client fee in bps, slippage tolerance, and the effective
505    /// router fee rates for the client, computes the exact fee amounts and the minimum
506    /// amount the user will receive.
507    ///
508    /// # Errors
509    ///
510    /// Returns an error when the combined fees exceed 100%, which would make the on-chain
511    /// call revert with `FeeCalculator__FeeTooHigh`.
512    fn calculate_fee_breakdown(
513        swap_output: &BigUint,
514        client_fee_bps: u16,
515        slippage: f64,
516        fee_rates: FeeRates,
517    ) -> Result<FeeBreakdown, EncodingError> {
518        let max_fee_units = fee_rates.max_fee_units();
519        // Scale the client fee from legacy bps (10_000 = 100%) to fee units so both fee
520        // types share the same denominator, exactly as the contract does.
521        let scaled_client_fee = client_fee_bps as u64 * fee_rates.fee_units_per_bps();
522        let fee_on_output = fee_rates.on_output() as u64;
523        let fee_on_client_fee = fee_rates.on_client_fee() as u64;
524
525        if scaled_client_fee + fee_on_output > max_fee_units {
526            return Err(EncodingError::FatalError(format!(
527                "client fee ({client_fee_bps} bps) plus router fee on output \
528                 ({fee_on_output} fee units) exceed the {max_fee_units} fee-unit cap (100%); \
529                 the router would revert"
530            )));
531        }
532        if fee_on_client_fee > max_fee_units {
533            return Err(EncodingError::FatalError(format!(
534                "router fee on client fee ({fee_on_client_fee} fee units) exceeds the \
535                 {max_fee_units} fee-unit cap (100%); the router would revert"
536            )));
537        }
538
539        let mut router_fee_on_client = BigUint::ZERO;
540        let mut client_portion = BigUint::ZERO;
541
542        if scaled_client_fee > 0 {
543            let client_fee_numerator = swap_output * scaled_client_fee;
544            let total_client_fee = &client_fee_numerator / max_fee_units;
545
546            router_fee_on_client = client_fee_numerator * fee_on_client_fee /
547                BigUint::from(fee_rates.max_fee_units_squared());
548
549            client_portion = total_client_fee - &router_fee_on_client;
550        }
551
552        let router_fee_on_output = swap_output * fee_on_output / max_fee_units;
553        let total_router_fee = router_fee_on_client + router_fee_on_output;
554
555        let amount_after_fees = swap_output - &client_portion - &total_router_fee;
556
557        let precision = BigUint::from(1_000_000u64);
558        let slippage_amount =
559            &amount_after_fees * BigUint::from((slippage * 1_000_000.0) as u64) / &precision;
560
561        let min_amount_received = &amount_after_fees - &slippage_amount;
562
563        Ok(FeeBreakdown::new(
564            total_router_fee,
565            client_portion,
566            slippage_amount,
567            min_amount_received,
568        ))
569    }
570}
571
572impl From<EncodingError> for SolveError {
573    fn from(err: EncodingError) -> Self {
574        SolveError::FailedEncoding(err.to_string())
575    }
576}
577
578/// Returns whether the quote routes through an exclusive pool, i.e. any swap in its route carries
579/// a committed amount.
580fn has_exclusive_leg(quote: &OrderQuote) -> bool {
581    quote.route().is_some_and(|route| {
582        route
583            .swaps()
584            .iter()
585            .any(|s| s.committed_amount_out().is_some())
586    })
587}
588
589#[cfg(test)]
590mod tests {
591    use std::collections::HashMap;
592
593    use num_bigint::BigUint;
594    use rstest::rstest;
595    use tycho_execution::encoding::{
596        errors::EncodingError,
597        models::{EncodedSolution, Solution},
598        tycho_encoder::TychoEncoder,
599    };
600    use tycho_simulation::tycho_core::{
601        models::{token::Token, Address, Chain as SimChain},
602        Bytes,
603    };
604
605    use super::*;
606    use crate::{
607        algorithm::test_utils::{component, MockProtocolSim},
608        BlockInfo, OrderQuote, QuoteStatus,
609    };
610
611    fn make_token(addr: Address) -> Token {
612        Token {
613            address: addr,
614            symbol: "T".to_string(),
615            decimals: 18,
616            tax: Default::default(),
617            gas: vec![],
618            chain: SimChain::Ethereum,
619            quality: 100,
620        }
621    }
622
623    fn make_route_swap_addrs(token_in: Address, token_out: Address) -> crate::types::Swap {
624        let tin = make_token(token_in.clone());
625        let tout = make_token(token_out.clone());
626        // Component ID must be a valid address for the USV2 swap encoder
627        let pool_addr = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
628        crate::types::Swap::new(
629            pool_addr.to_string(),
630            "uniswap_v2".to_string(),
631            token_in,
632            token_out,
633            BigUint::from(1000u64),
634            BigUint::from(990u64),
635            BigUint::from(50_000u64),
636            component(pool_addr, &[tin, tout]),
637            Box::new(MockProtocolSim::default()),
638        )
639    }
640
641    /// Builds a `Route` with both swaps and the token map populated, mirroring
642    /// what the algorithms do in production.
643    fn make_route_with_tokens(pairs: &[(Address, Address)]) -> crate::types::Route {
644        let mut tokens = HashMap::new();
645        let swaps = pairs
646            .iter()
647            .map(|(tin, tout)| {
648                tokens
649                    .entry(tin.clone())
650                    .or_insert_with(|| make_token(tin.clone()));
651                tokens
652                    .entry(tout.clone())
653                    .or_insert_with(|| make_token(tout.clone()));
654                make_route_swap_addrs(tin.clone(), tout.clone())
655            })
656            .collect();
657        crate::types::Route::new(swaps, tokens).expect("non-empty route")
658    }
659
660    fn make_address(byte: u8) -> Address {
661        Address::from([byte; 20])
662    }
663
664    fn make_order_quote(amount_out: u64) -> OrderQuote {
665        OrderQuote::new(
666            "test-order".to_string(),
667            QuoteStatus::Success,
668            BigUint::from(1000u64),
669            BigUint::from(amount_out),
670            BigUint::from(100_000u64),
671            BigUint::from(amount_out),
672            BlockInfo::new(1, "0x123".to_string(), 1000),
673            "test".to_string(),
674            Bytes::from(make_address(0xAA).as_ref()),
675            Bytes::from(make_address(0xAA).as_ref()),
676            "1".to_string(),
677        )
678    }
679
680    struct MockTychoEncoder;
681
682    impl TychoEncoder for MockTychoEncoder {
683        fn encode_solutions(
684            &self,
685            _solutions: Vec<Solution>,
686        ) -> Result<Vec<EncodedSolution>, EncodingError> {
687            Ok(vec![])
688        }
689
690        fn validate_solution(&self, _solution: &Solution) -> Result<(), EncodingError> {
691            Ok(())
692        }
693    }
694
695    fn mock_encoder(chain: Chain) -> Encoder {
696        let router_fees = SharedRouterFees::default();
697        router_fees.set(RouterFees::new(FEE_SCALE, 100_000, 20_000_000, HashMap::new()));
698        Encoder {
699            tycho_encoder: Some(Box::new(MockTychoEncoder)),
700            chain,
701            router_address: Some(Bytes::from([0u8; 20].as_ref())),
702            router_fees,
703            exclusive_swap_signer: None,
704        }
705    }
706
707    #[test]
708    fn test_encoder_new_disabled_on_unsupported_chain() {
709        // Starknet has no entry in ROUTER_ADDRESSES_JSON.
710        // Build a registry for Ethereum (which is valid) but pass Starknet to Encoder::new —
711        // this must succeed with a disabled encoder rather than fail.
712        let registry =
713            tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry::new(Chain::Ethereum)
714                .add_default_encoders(None)
715                .expect("registry should build for Ethereum");
716        let encoder = Encoder::new(Chain::Starknet, registry)
717            .expect("new must not fail for a router-less chain");
718        assert!(
719            encoder.router_address().is_none(),
720            "expected disabled encoder, got a router address"
721        );
722    }
723
724    #[tokio::test]
725    async fn disabled_encoder_quotes_but_refuses_to_encode() {
726        // A chain with no Tycho router deployment yields a disabled encoder.
727        let registry = SwapEncoderRegistry::new(Chain::Ethereum)
728            .add_default_encoders(None)
729            .unwrap();
730        let encoder =
731            Encoder::new(Chain::Starknet, registry).expect("new must not fail when disabled");
732        assert!(encoder.router_address().is_none());
733
734        let err = encoder
735            .encode(vec![], EncodingOptions::new(0.01))
736            .await
737            .expect_err("encoding must fail on a router-less chain");
738        assert!(matches!(err, SolveError::EncodingUnavailable(_)));
739    }
740
741    #[test]
742    fn test_try_from_without_route_errors() {
743        let quote = make_order_quote(990);
744
745        let result = Solution::try_from(&quote);
746
747        assert!(result.is_err());
748    }
749
750    #[test]
751    fn test_try_from_non_success_errors() {
752        let quote = OrderQuote::new(
753            "test-order".to_string(),
754            QuoteStatus::NoRouteFound,
755            BigUint::from(1000u64),
756            BigUint::from(990u64),
757            BigUint::from(100_000u64),
758            BigUint::from(990u64),
759            BlockInfo::new(1, "0x123".to_string(), 1000),
760            "test".to_string(),
761            Bytes::from(make_address(0xAA).as_ref()),
762            Bytes::from(make_address(0xAA).as_ref()),
763            "1".to_string(),
764        );
765
766        let result = Solution::try_from(&quote);
767
768        assert!(result.is_err());
769    }
770
771    #[test]
772    fn test_try_from_maps_tokens_and_amounts() {
773        let quote = make_order_quote(990)
774            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
775
776        let solution = Solution::try_from(&quote).unwrap();
777
778        assert_eq!(*solution.token_in(), Bytes::from(make_address(0x01).as_ref()));
779        assert_eq!(*solution.token_out(), Bytes::from(make_address(0x02).as_ref()));
780        assert_eq!(*solution.amount_in(), *quote.amount_in());
781        assert_eq!(*solution.min_amount_out(), *quote.amount_out());
782        assert_eq!(solution.swaps().len(), 1);
783    }
784
785    const CONTROLLER_KEY: &str =
786        "0x1111111111111111111111111111111111111111111111111111111111111111";
787
788    fn ekubo_signed_swap(committed: Option<u64>) -> crate::types::Swap {
789        let token_in = make_address(0x11);
790        let token_out = make_address(0x22);
791        let mut comp = component("ekubo-signed-pool", &[]);
792        comp.static_attributes
793            .insert("extension".to_string(), Bytes::from([0x55u8; 20].as_ref()));
794        comp.static_attributes
795            .insert("fee".to_string(), Bytes::from(0u64));
796        comp.static_attributes
797            .insert("pool_type_config".to_string(), Bytes::from(0u32));
798
799        let mut swap = crate::types::Swap::new(
800            "ekubo-signed-pool".to_string(),
801            "ekubo_v3".to_string(),
802            token_in,
803            token_out,
804            BigUint::from(1_000_000u64),
805            BigUint::from(1_000_000u64),
806            BigUint::from(50_000u64),
807            comp,
808            Box::new(MockProtocolSim::default()),
809        );
810        if let Some(committed) = committed {
811            swap.set_committed_amount_out(BigUint::from(committed));
812        }
813        swap
814    }
815
816    fn single_swap_route(swap: crate::types::Swap) -> crate::types::Route {
817        let tokens = HashMap::from([
818            (swap.token_in().clone(), make_token(swap.token_in().clone())),
819            (swap.token_out().clone(), make_token(swap.token_out().clone())),
820        ]);
821        crate::types::Route::new(vec![swap], tokens).expect("non-empty route")
822    }
823
824    #[rstest]
825    #[case::exclusive_leg_signed(Some(990_000), true)]
826    #[case::public_leg_untouched(None, false)]
827    fn test_stamp_exclusive_swaps(#[case] committed: Option<u64>, #[case] signed: bool) {
828        let quote =
829            make_order_quote(990_000).with_route(single_swap_route(ekubo_signed_swap(committed)));
830        let signer = ExclusiveSwapSigner::new(CONTROLLER_KEY.parse().unwrap(), 1, 0, 120);
831
832        let solution =
833            Encoder::stamp_exclusive_swaps(Solution::try_from(&quote).unwrap(), &quote, &signer)
834                .unwrap();
835
836        assert_eq!(
837            solution.swaps()[0]
838                .user_data()
839                .is_some(),
840            signed
841        );
842    }
843
844    #[tokio::test]
845    async fn test_encode_rejects_exclusive_leg_without_signer() {
846        // mock_encoder has no exclusive_swap_signer, so an exclusive leg must fail fast rather than
847        // produce unsigned (on-chain-invalid) calldata.
848        let encoder = mock_encoder(Chain::Ethereum);
849        let quote = make_order_quote(990_000)
850            .with_route(single_swap_route(ekubo_signed_swap(Some(990_000))));
851
852        let result = encoder
853            .encode(vec![quote], EncodingOptions::new(0.01))
854            .await;
855
856        assert!(result.is_err(), "expected fail-fast error for unsigned exclusive leg");
857    }
858
859    #[test]
860    fn test_try_from_multi_hop_uses_boundary_swap_tokens() {
861        let quote = make_order_quote(990).with_route(make_route_with_tokens(&[
862            (make_address(0x01), make_address(0x02)),
863            (make_address(0x02), make_address(0x03)),
864        ]));
865
866        let solution = Solution::try_from(&quote).unwrap();
867
868        assert_eq!(*solution.token_in(), Bytes::from(make_address(0x01).as_ref()));
869        assert_eq!(*solution.token_out(), Bytes::from(make_address(0x03).as_ref()));
870        assert_eq!(solution.swaps().len(), 2);
871    }
872
873    #[tokio::test]
874    async fn test_encode_skips_non_successful_solutions() {
875        let encoder = mock_encoder(Chain::Ethereum);
876        let quote = OrderQuote::new(
877            "test-order".to_string(),
878            QuoteStatus::NoRouteFound,
879            BigUint::from(1000u64),
880            BigUint::from(990u64),
881            BigUint::from(100_000u64),
882            BigUint::from(990u64),
883            BlockInfo::new(1, "0x123".to_string(), 1000),
884            "test".to_string(),
885            Bytes::from(make_address(0xAA).as_ref()),
886            Bytes::from(make_address(0xAA).as_ref()),
887            "1".to_string(),
888        );
889
890        let encoding_options = EncodingOptions::new(0.01);
891
892        let result = encoder
893            .encode(vec![quote], encoding_options)
894            .await
895            .unwrap();
896
897        assert!(result[0].transaction().is_none());
898    }
899
900    fn real_encoder() -> Encoder {
901        let registry = SwapEncoderRegistry::new(Chain::Ethereum)
902            .add_default_encoders(None)
903            .unwrap();
904        let encoder = Encoder::new(Chain::Ethereum, registry).unwrap();
905        // Load fees so encode() can run; in production the fetcher supplies on-chain values.
906        encoder
907            .router_fees()
908            .set(RouterFees::new(FEE_SCALE, 100_000, 20_000_000, HashMap::new()));
909        encoder
910    }
911
912    #[tokio::test]
913    async fn test_encode_sets_transaction_on_successful_solution() {
914        let encoder = real_encoder();
915        let quote = make_order_quote(990)
916            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
917
918        let encoding_options = EncodingOptions::new(0.01);
919
920        let result = encoder
921            .encode(vec![quote], encoding_options)
922            .await
923            .unwrap();
924
925        assert!(result[0].transaction().is_some());
926        let tx = result[0].transaction().unwrap();
927        assert!(!tx.data().is_empty());
928        // Data starts with a 4-byte function selector
929        assert!(tx.data().len() > 4);
930    }
931
932    #[tokio::test]
933    async fn test_encode_with_client_fee_params() {
934        let encoder = real_encoder();
935        let quote = make_order_quote(990)
936            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
937
938        let fee = crate::ClientFeeParams::new(
939            100,
940            Bytes::from(make_address(0xBB).as_ref()),
941            BigUint::from(0u64),
942            1_893_456_000u64,
943            Bytes::from(vec![0xAB; 65]),
944        );
945        let encoding_options = EncodingOptions::new(0.01).with_client_fee_params(fee);
946
947        let result = encoder
948            .encode(vec![quote], encoding_options)
949            .await
950            .unwrap();
951
952        assert!(result[0].transaction().is_some());
953        let tx = result[0].transaction().unwrap();
954        assert!(!tx.data().is_empty());
955        // Calldata with fee params should be longer than without
956        assert!(tx.data().len() > 4);
957    }
958
959    #[tokio::test]
960    async fn test_encode_without_client_fee_produces_transaction() {
961        let encoder = real_encoder();
962        let quote = make_order_quote(990)
963            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
964
965        let encoding_options = EncodingOptions::new(0.01);
966
967        let result = encoder
968            .encode(vec![quote], encoding_options)
969            .await
970            .unwrap();
971
972        assert!(result[0].transaction().is_some());
973    }
974
975    // ==================== Signature Offset Tests ====================
976
977    fn make_client_fee(bps: u16) -> crate::ClientFeeParams {
978        crate::ClientFeeParams::new(
979            bps,
980            Bytes::from(make_address(0xBB).as_ref()),
981            BigUint::from(0u64),
982            1_893_456_000u64,
983            Bytes::from(vec![]),
984        )
985    }
986
987    #[tokio::test]
988    async fn test_encode_with_client_fee_returns_signature_offset() {
989        let encoder = real_encoder();
990        let quote = make_order_quote(990)
991            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
992        let opts = EncodingOptions::new(0.01).with_client_fee_params(make_client_fee(100));
993
994        let result = encoder
995            .encode(vec![quote], opts)
996            .await
997            .unwrap();
998
999        let tx = result[0].transaction().unwrap();
1000        tx.client_fee_signature_offset()
1001            .expect("client_fee_signature_offset must be present with client fee");
1002    }
1003
1004    #[tokio::test]
1005    async fn test_encode_without_client_fee_has_no_signature_offset() {
1006        let encoder = real_encoder();
1007        let quote = make_order_quote(990)
1008            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1009        let opts = EncodingOptions::new(0.01);
1010
1011        let result = encoder
1012            .encode(vec![quote], opts)
1013            .await
1014            .unwrap();
1015
1016        let tx = result[0].transaction().unwrap();
1017        assert!(tx
1018            .client_fee_signature_offset()
1019            .is_none());
1020    }
1021
1022    #[tokio::test]
1023    async fn test_signature_offset_allows_patching() {
1024        let encoder = real_encoder();
1025        let real_sig = vec![0xFF; 65];
1026        let quote = make_order_quote(990)
1027            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1028        let opts = EncodingOptions::new(0.01).with_client_fee_params(make_client_fee(100));
1029
1030        let result = encoder
1031            .encode(vec![quote], opts)
1032            .await
1033            .unwrap();
1034
1035        let tx = result[0].transaction().unwrap();
1036        let offset = tx
1037            .client_fee_signature_offset()
1038            .unwrap();
1039
1040        let mut calldata = tx.data().to_vec();
1041        calldata[offset..offset + 65].copy_from_slice(&real_sig);
1042        assert_eq!(&calldata[offset..offset + 65], &real_sig[..]);
1043    }
1044
1045    // ==================== Fee Breakdown Tests ====================
1046
1047    /// FeeCalculator precision used in these tests: 100% = 100,000,000 fee units.
1048    const FEE_SCALE: u64 = 100_000_000;
1049
1050    #[test]
1051    fn test_calculate_fee_breakdown() {
1052        // 10 bps router fee on output, 20% router share of the client fee, 1% client fee.
1053        let rates = FeeRates::new(100_000, 20_000_000, FEE_SCALE);
1054
1055        let breakdown =
1056            Encoder::calculate_fee_breakdown(&BigUint::from(1_000_000u64), 100, 0.0, rates)
1057                .unwrap();
1058
1059        // total client fee = 1% of 1_000_000 = 10_000; router takes 20% of it = 2_000.
1060        // router fee on output = 0.1% of 1_000_000 = 1_000.
1061        assert_eq!(*breakdown.client_fee(), BigUint::from(8_000u64));
1062        assert_eq!(*breakdown.router_fee(), BigUint::from(3_000u64));
1063        assert_eq!(*breakdown.min_amount_received(), BigUint::from(989_000u64));
1064    }
1065
1066    #[test]
1067    fn test_calculate_fee_breakdown_zero_fees() {
1068        let rates = FeeRates::new(0, 0, FEE_SCALE);
1069
1070        let breakdown =
1071            Encoder::calculate_fee_breakdown(&BigUint::from(1_000_000u64), 0, 0.0, rates).unwrap();
1072
1073        assert_eq!(*breakdown.client_fee(), BigUint::ZERO);
1074        assert_eq!(*breakdown.router_fee(), BigUint::ZERO);
1075        assert_eq!(*breakdown.min_amount_received(), BigUint::from(1_000_000u64));
1076    }
1077
1078    #[test]
1079    fn test_calculate_fee_breakdown_fee_too_high() {
1080        // 100% client fee plus any router fee on output exceeds the maximum.
1081        let rates = FeeRates::new(1, 0, FEE_SCALE);
1082
1083        let result =
1084            Encoder::calculate_fee_breakdown(&BigUint::from(1_000_000u64), 10_000, 0.0, rates);
1085
1086        assert!(result.is_err());
1087    }
1088
1089    #[tokio::test]
1090    async fn test_encode_uses_custom_fees_for_client_fee_receiver() {
1091        let encoder = real_encoder();
1092        // Default 1% router fee on output; receiver 0xBB pays no router fees at all.
1093        let custom = HashMap::from([(Bytes::from(make_address(0xBB).as_ref()), (0u32, 0u32))]);
1094        encoder
1095            .router_fees()
1096            .set(RouterFees::new(FEE_SCALE, 1_000_000, 20_000_000, custom));
1097        let quote = make_order_quote(1_000_000_000)
1098            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1099        let opts = EncodingOptions::new(0.0).with_client_fee_params(make_client_fee(100));
1100
1101        let result = encoder
1102            .encode(vec![quote], opts)
1103            .await
1104            .unwrap();
1105
1106        let breakdown = result[0].fee_breakdown().unwrap();
1107        assert_eq!(*breakdown.router_fee(), BigUint::ZERO);
1108        // Client keeps the full 1% fee since the router's share is overridden to zero.
1109        assert_eq!(*breakdown.client_fee(), BigUint::from(10_000_000u64));
1110    }
1111
1112    #[tokio::test]
1113    async fn test_encode_falls_back_to_sender() {
1114        let encoder = real_encoder();
1115        // The order sender (0xAA) has a custom zero router fee on output; client-fee share
1116        // inherits the 20% default.
1117        let custom =
1118            HashMap::from([(Bytes::from(make_address(0xAA).as_ref()), (0u32, 20_000_000u32))]);
1119        encoder
1120            .router_fees()
1121            .set(RouterFees::new(FEE_SCALE, 1_000_000, 20_000_000, custom));
1122        let quote = make_order_quote(1_000_000_000)
1123            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1124
1125        let result = encoder
1126            .encode(vec![quote], EncodingOptions::new(0.0))
1127            .await
1128            .unwrap();
1129
1130        let breakdown = result[0].fee_breakdown().unwrap();
1131        assert_eq!(*breakdown.router_fee(), BigUint::ZERO);
1132    }
1133
1134    #[tokio::test]
1135    async fn test_encode_unknown_client() {
1136        let encoder = real_encoder();
1137        encoder
1138            .router_fees()
1139            .set(RouterFees::new(FEE_SCALE, 1_000_000, 20_000_000, HashMap::new()));
1140        let quote = make_order_quote(1_000_000_000)
1141            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
1142
1143        let result = encoder
1144            .encode(vec![quote], EncodingOptions::new(0.0))
1145            .await
1146            .unwrap();
1147
1148        let breakdown = result[0].fee_breakdown().unwrap();
1149        // 1% of 1_000_000_000.
1150        assert_eq!(*breakdown.router_fee(), BigUint::from(10_000_000u64));
1151    }
1152}