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