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