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