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