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::{EncodingOptions, FeeBreakdown, OrderQuote, QuoteStatus, SolveError, Transaction};
24
25/// Canonical Permit2 contract address — identical on all EVM chains.
26pub const PERMIT2_ADDRESS: &str = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
27
28/// Router fee on swap output amount: 10 basis points (0.1%).
29const ROUTER_FEE_ON_OUTPUT_BPS: u64 = 10;
30/// Router's share of the client fee: 2000 basis points (20%).
31const ROUTER_FEE_ON_CLIENT_FEE_BPS: u64 = 2000;
32
33/// Encodes solution into tycho compatible transactions.
34///
35/// # Fields
36/// * `tycho_encoder` - Encoder created using the configured chain for encoding solutions into tycho
37///   compatible transactions
38/// * `chain` - Chain to be used.
39/// * `router_address` - Address of the Tycho Router contract on this chain.
40pub struct Encoder {
41    tycho_encoder: Box<dyn TychoEncoder>,
42    chain: Chain,
43    router_address: Bytes,
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        })
134    }
135
136    /// Returns the Tycho Router contract address for this chain.
137    pub fn router_address(&self) -> &Bytes {
138        &self.router_address
139    }
140
141    /// Encodes order solutions for execution.
142    ///
143    /// # Arguments
144    /// * `solutions` - Array containing order solutions.
145    /// * `encoding_options` - Additional context needed for encoding.
146    ///
147    /// # Returns
148    /// Input order solutions with the encoded transaction added to each successful solution.
149    pub async fn encode(
150        &self,
151        mut quotes: Vec<OrderQuote>,
152        encoding_options: EncodingOptions,
153    ) -> Result<Vec<OrderQuote>, SolveError> {
154        let slippage = encoding_options.slippage();
155        if slippage == 0.0 {
156            tracing::warn!("slippage is 0, transaction will likely revert");
157        } else if slippage > 0.5 {
158            tracing::warn!(slippage, "slippage exceeds 50%, possible misconfiguration");
159        }
160
161        let mut to_encode: Vec<(usize, Solution)> = Vec::new();
162
163        for (i, quote) in quotes.iter().enumerate() {
164            if quote.status() != QuoteStatus::Success {
165                continue;
166            }
167
168            to_encode.push((
169                i,
170                Solution::try_from(quote)?
171                    .with_user_transfer_type(encoding_options.transfer_type().clone()),
172            ));
173        }
174
175        let solutions: Vec<Solution> = to_encode
176            .iter()
177            .map(|(_, s)| s.clone())
178            .collect();
179        let encoded_solutions = self
180            .tycho_encoder
181            .encode_solutions(solutions)?;
182
183        for (encoded_solution, (idx, solution)) in encoded_solutions
184            .into_iter()
185            .zip(to_encode)
186        {
187            quotes[idx].set_gas_estimate(encoded_solution.estimated_gas().clone());
188            let (transaction, fee_breakdown) =
189                self.encode_tycho_router_call(encoded_solution, &solution, &encoding_options)?;
190            quotes[idx].set_transaction(transaction);
191            quotes[idx].set_fee_breakdown(fee_breakdown);
192        }
193
194        Ok(quotes)
195    }
196
197    /// Encodes a call using one of the router's swap methods.
198    ///
199    /// Selects the appropriate router function based on the function signature in
200    /// `encoded_solution` (single/sequential/split, with optional Permit2 or Vault variants),
201    /// prepends the 4-byte selector, and returns a `Transaction` ready for submission.
202    ///
203    /// Fee calculation mirrors the on-chain `FeeCalculator.calculateFee` using identical
204    /// integer arithmetic so `min_amount_out` passes the router's post-fee check.
205    fn encode_tycho_router_call(
206        &self,
207        encoded_solution: EncodedSolution,
208        solution: &Solution,
209        encoding_options: &EncodingOptions,
210    ) -> Result<(Transaction, FeeBreakdown), EncodingError> {
211        let amount_in = biguint_to_u256(solution.amount_in());
212        let swap_output = solution.min_amount_out();
213        let fee_breakdown = Self::calculate_fee_breakdown(
214            swap_output,
215            encoding_options
216                .client_fee_params()
217                .map_or(0, |f| f.bps()),
218            encoding_options.slippage(),
219        );
220        let min_amount_out = biguint_to_u256(fee_breakdown.min_amount_received());
221        let native_address = &self.chain.native_token().address;
222        let router_eth = Address::from_slice(ROUTER_ETH_ADDRESS.as_ref());
223        let to_router_address = |raw: Address| {
224            if raw.as_slice() == native_address.as_ref() {
225                router_eth
226            } else {
227                raw
228            }
229        };
230
231        let token_in = to_router_address(bytes_to_address(solution.token_in())?);
232        let token_out = to_router_address(bytes_to_address(solution.token_out())?);
233        let receiver = bytes_to_address(solution.receiver())?;
234
235        let (permit, permit2_sig) = if let Some(p) = encoding_options.permit() {
236            let d = p.details();
237            let permit = Some(PermitSingle {
238                details: SolPermitDetails {
239                    token: bytes_to_address(d.token())?,
240                    amount: U160::from(biguint_to_u256(d.amount())),
241                    expiration: U48::from(biguint_to_u256(d.expiration())),
242                    nonce: U48::from(biguint_to_u256(d.nonce())),
243                },
244                spender: bytes_to_address(p.spender())?,
245                sigDeadline: biguint_to_u256(p.sig_deadline()),
246            });
247            let sig = encoding_options
248                .permit2_signature()
249                .ok_or_else(|| {
250                    EncodingError::FatalError("Signature must be provided for permit2".to_string())
251                })?
252                .to_vec();
253            (permit, sig)
254        } else {
255            (None, vec![])
256        };
257
258        let client_fee_params = if let Some(fee) = encoding_options.client_fee_params() {
259            (
260                fee.bps(),
261                bytes_to_address(fee.receiver())?,
262                biguint_to_u256(fee.max_contribution()),
263                U256::from(fee.deadline()),
264                // Pad to 65 bytes so the ABI encoding always reserves room for
265                // the client to patch the real EIP-712 signature after signing.
266                {
267                    let mut sig = fee.signature().to_vec();
268                    sig.resize(65, 0);
269                    sig
270                },
271            )
272        } else {
273            (0u16, Address::ZERO, U256::ZERO, U256::MAX, vec![])
274        };
275
276        let fn_sig = encoded_solution.function_signature();
277        let swaps = encoded_solution.swaps();
278        let fee_breakdown = if encoding_options
279            .client_fee_params()
280            .is_some()
281        {
282            fee_breakdown.with_swaps_hash(keccak256(swaps).0)
283        } else {
284            fee_breakdown
285        };
286
287        let method_calldata = if fn_sig.contains("Permit2") {
288            let permit = permit.ok_or(EncodingError::FatalError(
289                "permit2 object must be set to use permit2".to_string(),
290            ))?;
291            if fn_sig.contains("splitSwap") {
292                (
293                    amount_in,
294                    token_in,
295                    token_out,
296                    min_amount_out,
297                    U256::from(encoded_solution.n_tokens()),
298                    receiver,
299                    client_fee_params,
300                    permit,
301                    permit2_sig,
302                    swaps,
303                )
304                    .abi_encode()
305            } else {
306                (
307                    amount_in,
308                    token_in,
309                    token_out,
310                    min_amount_out,
311                    receiver,
312                    client_fee_params,
313                    permit,
314                    permit2_sig,
315                    swaps,
316                )
317                    .abi_encode()
318            }
319        } else if fn_sig.contains("splitSwap") {
320            (
321                amount_in,
322                token_in,
323                token_out,
324                min_amount_out,
325                U256::from(encoded_solution.n_tokens()),
326                receiver,
327                client_fee_params,
328                swaps,
329            )
330                .abi_encode()
331        } else if fn_sig.contains("singleSwap") || fn_sig.contains("sequentialSwap") {
332            (amount_in, token_in, token_out, min_amount_out, receiver, client_fee_params, swaps)
333                .abi_encode()
334        } else {
335            return Err(EncodingError::FatalError(format!(
336                "unsupported function signature for Tycho router: {fn_sig}"
337            )));
338        };
339
340        let contract_interaction =
341            Self::encode_input(encoded_solution.function_signature(), method_calldata);
342
343        let value =
344            if token_in == router_eth { solution.amount_in().clone() } else { BigUint::ZERO };
345        let mut transaction = Transaction::new(
346            encoded_solution
347                .interacting_with()
348                .clone(),
349            value,
350            contract_interaction,
351        );
352        if encoding_options
353            .client_fee_params()
354            .is_some()
355        {
356            let offset = encoded_solution.client_fee_signature_offset();
357            transaction = transaction.with_client_fee_signature_offset(offset);
358        }
359        Ok((transaction, fee_breakdown))
360    }
361
362    /// Prepends the 4-byte Keccak selector for `selector` to the ABI-encoded args.
363    fn encode_input(selector: &str, mut encoded_args: Vec<u8>) -> Vec<u8> {
364        let mut hasher = Keccak256::new();
365        hasher.update(selector.as_bytes());
366        let selector_bytes = &hasher.finalize()[..4];
367        let mut call_data = selector_bytes.to_vec();
368        // Remove extra prefix if present (32 bytes for dynamic data)
369        // Alloy encoding is including a prefix for dynamic data indicating the offset or length
370        // but at this point we don't want that
371        if encoded_args.len() > 32 &&
372            encoded_args[..32] ==
373                [0u8; 31]
374                    .into_iter()
375                    .chain([32].to_vec())
376                    .collect::<Vec<u8>>()
377        {
378            encoded_args = encoded_args[32..].to_vec();
379        }
380        call_data.extend(encoded_args);
381        call_data
382    }
383
384    /// Mirrors the on-chain `FeeCalculator.calculateFee` using identical integer arithmetic.
385    ///
386    /// Given the raw swap output, client fee in bps, and slippage tolerance, computes
387    /// the exact fee amounts and the minimum amount the user will receive.
388    fn calculate_fee_breakdown(
389        swap_output: &BigUint,
390        client_fee_bps: u16,
391        slippage: f64,
392    ) -> FeeBreakdown {
393        let client_bps = client_fee_bps as u64;
394
395        let mut router_fee_on_client = BigUint::ZERO;
396        let mut client_portion = BigUint::ZERO;
397
398        if client_bps > 0 {
399            let fee_numerator = swap_output * client_bps;
400            let total_client_fee = &fee_numerator / 10_000u64;
401
402            router_fee_on_client = &fee_numerator * ROUTER_FEE_ON_CLIENT_FEE_BPS / 100_000_000u64;
403
404            client_portion = total_client_fee - &router_fee_on_client;
405        }
406
407        let router_fee_on_output = swap_output * ROUTER_FEE_ON_OUTPUT_BPS / 10_000u64;
408        let total_router_fee = router_fee_on_client + router_fee_on_output;
409
410        let amount_after_fees = swap_output - &client_portion - &total_router_fee;
411
412        let precision = BigUint::from(1_000_000u64);
413        let slippage_amount =
414            &amount_after_fees * BigUint::from((slippage * 1_000_000.0) as u64) / &precision;
415
416        let min_amount_received = &amount_after_fees - &slippage_amount;
417
418        FeeBreakdown::new(total_router_fee, client_portion, slippage_amount, min_amount_received)
419    }
420}
421
422impl From<EncodingError> for SolveError {
423    fn from(err: EncodingError) -> Self {
424        SolveError::FailedEncoding(err.to_string())
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use std::collections::HashMap;
431
432    use num_bigint::BigUint;
433    use tycho_execution::encoding::{
434        errors::EncodingError,
435        models::{EncodedSolution, Solution},
436        tycho_encoder::TychoEncoder,
437    };
438    use tycho_simulation::tycho_core::{
439        models::{token::Token, Address, Chain as SimChain},
440        Bytes,
441    };
442
443    use super::*;
444    use crate::{
445        algorithm::test_utils::{component, MockProtocolSim},
446        BlockInfo, OrderQuote, QuoteStatus,
447    };
448
449    fn make_token(addr: Address) -> Token {
450        Token {
451            address: addr,
452            symbol: "T".to_string(),
453            decimals: 18,
454            tax: Default::default(),
455            gas: vec![],
456            chain: SimChain::Ethereum,
457            quality: 100,
458        }
459    }
460
461    fn make_route_swap_addrs(token_in: Address, token_out: Address) -> crate::types::Swap {
462        let tin = make_token(token_in.clone());
463        let tout = make_token(token_out.clone());
464        // Component ID must be a valid address for the USV2 swap encoder
465        let pool_addr = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
466        crate::types::Swap::new(
467            pool_addr.to_string(),
468            "uniswap_v2".to_string(),
469            token_in,
470            token_out,
471            BigUint::from(1000u64),
472            BigUint::from(990u64),
473            BigUint::from(50_000u64),
474            component(pool_addr, &[tin, tout]),
475            Box::new(MockProtocolSim::default()),
476        )
477    }
478
479    /// Builds a `Route` with both swaps and the token map populated, mirroring
480    /// what the algorithms do in production.
481    fn make_route_with_tokens(pairs: &[(Address, Address)]) -> crate::types::Route {
482        let mut tokens = HashMap::new();
483        let swaps = pairs
484            .iter()
485            .map(|(tin, tout)| {
486                tokens
487                    .entry(tin.clone())
488                    .or_insert_with(|| make_token(tin.clone()));
489                tokens
490                    .entry(tout.clone())
491                    .or_insert_with(|| make_token(tout.clone()));
492                make_route_swap_addrs(tin.clone(), tout.clone())
493            })
494            .collect();
495        crate::types::Route::new(swaps, tokens)
496    }
497
498    fn make_address(byte: u8) -> Address {
499        Address::from([byte; 20])
500    }
501
502    fn make_order_quote() -> OrderQuote {
503        OrderQuote::new(
504            "test-order".to_string(),
505            QuoteStatus::Success,
506            BigUint::from(1000u64),
507            BigUint::from(990u64),
508            BigUint::from(100_000u64),
509            BigUint::from(990u64),
510            BlockInfo::new(1, "0x123".to_string(), 1000),
511            "test".to_string(),
512            Bytes::from(make_address(0xAA).as_ref()),
513            Bytes::from(make_address(0xAA).as_ref()),
514            "1".to_string(),
515        )
516    }
517
518    struct MockTychoEncoder;
519
520    impl TychoEncoder for MockTychoEncoder {
521        fn encode_solutions(
522            &self,
523            _solutions: Vec<Solution>,
524        ) -> Result<Vec<EncodedSolution>, EncodingError> {
525            Ok(vec![])
526        }
527
528        fn validate_solution(&self, _solution: &Solution) -> Result<(), EncodingError> {
529            Ok(())
530        }
531    }
532
533    fn mock_encoder(chain: Chain) -> Encoder {
534        Encoder {
535            tycho_encoder: Box::new(MockTychoEncoder),
536            chain,
537            router_address: Bytes::from([0u8; 20].as_ref()),
538        }
539    }
540
541    #[test]
542    fn test_encoder_new_fails_on_unsupported_chain() {
543        // Starknet has no entry in ROUTER_ADDRESSES_JSON.
544        // Build a registry for Ethereum (which is valid) but pass Starknet to Encoder::new —
545        // the router address lookup must fail before the encoder builder is invoked.
546        let registry =
547            tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry::new(Chain::Ethereum)
548                .add_default_encoders(None)
549                .expect("registry should build for Ethereum");
550        let result = Encoder::new(Chain::Starknet, registry);
551        assert!(result.is_err(), "expected Err for chain without router address, got Ok");
552    }
553
554    #[test]
555    fn test_try_from_without_route_errors() {
556        let quote = make_order_quote();
557
558        let result = Solution::try_from(&quote);
559
560        assert!(result.is_err());
561    }
562
563    #[test]
564    fn test_try_from_non_success_errors() {
565        let quote = OrderQuote::new(
566            "test-order".to_string(),
567            QuoteStatus::NoRouteFound,
568            BigUint::from(1000u64),
569            BigUint::from(990u64),
570            BigUint::from(100_000u64),
571            BigUint::from(990u64),
572            BlockInfo::new(1, "0x123".to_string(), 1000),
573            "test".to_string(),
574            Bytes::from(make_address(0xAA).as_ref()),
575            Bytes::from(make_address(0xAA).as_ref()),
576            "1".to_string(),
577        );
578
579        let result = Solution::try_from(&quote);
580
581        assert!(result.is_err());
582    }
583
584    #[test]
585    fn test_try_from_maps_tokens_and_amounts() {
586        let quote = make_order_quote()
587            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
588
589        let solution = Solution::try_from(&quote).unwrap();
590
591        assert_eq!(*solution.token_in(), Bytes::from(make_address(0x01).as_ref()));
592        assert_eq!(*solution.token_out(), Bytes::from(make_address(0x02).as_ref()));
593        assert_eq!(*solution.amount_in(), *quote.amount_in());
594        assert_eq!(*solution.min_amount_out(), *quote.amount_out());
595        assert_eq!(solution.swaps().len(), 1);
596    }
597
598    #[test]
599    fn test_try_from_multi_hop_uses_boundary_swap_tokens() {
600        let quote = make_order_quote().with_route(make_route_with_tokens(&[
601            (make_address(0x01), make_address(0x02)),
602            (make_address(0x02), make_address(0x03)),
603        ]));
604
605        let solution = Solution::try_from(&quote).unwrap();
606
607        assert_eq!(*solution.token_in(), Bytes::from(make_address(0x01).as_ref()));
608        assert_eq!(*solution.token_out(), Bytes::from(make_address(0x03).as_ref()));
609        assert_eq!(solution.swaps().len(), 2);
610    }
611
612    #[tokio::test]
613    async fn test_encode_skips_non_successful_solutions() {
614        let encoder = mock_encoder(Chain::Ethereum);
615        let quote = OrderQuote::new(
616            "test-order".to_string(),
617            QuoteStatus::NoRouteFound,
618            BigUint::from(1000u64),
619            BigUint::from(990u64),
620            BigUint::from(100_000u64),
621            BigUint::from(990u64),
622            BlockInfo::new(1, "0x123".to_string(), 1000),
623            "test".to_string(),
624            Bytes::from(make_address(0xAA).as_ref()),
625            Bytes::from(make_address(0xAA).as_ref()),
626            "1".to_string(),
627        );
628
629        let encoding_options = EncodingOptions::new(0.01);
630
631        let result = encoder
632            .encode(vec![quote], encoding_options)
633            .await
634            .unwrap();
635
636        assert!(result[0].transaction().is_none());
637    }
638
639    fn real_encoder() -> Encoder {
640        let registry = SwapEncoderRegistry::new(Chain::Ethereum)
641            .add_default_encoders(None)
642            .unwrap();
643        Encoder::new(Chain::Ethereum, registry).unwrap()
644    }
645
646    #[tokio::test]
647    async fn test_encode_sets_transaction_on_successful_solution() {
648        let encoder = real_encoder();
649        let quote = make_order_quote()
650            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
651
652        let encoding_options = EncodingOptions::new(0.01);
653
654        let result = encoder
655            .encode(vec![quote], encoding_options)
656            .await
657            .unwrap();
658
659        assert!(result[0].transaction().is_some());
660        let tx = result[0].transaction().unwrap();
661        assert!(!tx.data().is_empty());
662        // Data starts with a 4-byte function selector
663        assert!(tx.data().len() > 4);
664    }
665
666    #[tokio::test]
667    async fn test_encode_with_client_fee_params() {
668        let encoder = real_encoder();
669        let quote = make_order_quote()
670            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
671
672        let fee = crate::ClientFeeParams::new(
673            100,
674            Bytes::from(make_address(0xBB).as_ref()),
675            BigUint::from(0u64),
676            1_893_456_000u64,
677            Bytes::from(vec![0xAB; 65]),
678        );
679        let encoding_options = EncodingOptions::new(0.01).with_client_fee_params(fee);
680
681        let result = encoder
682            .encode(vec![quote], encoding_options)
683            .await
684            .unwrap();
685
686        assert!(result[0].transaction().is_some());
687        let tx = result[0].transaction().unwrap();
688        assert!(!tx.data().is_empty());
689        // Calldata with fee params should be longer than without
690        assert!(tx.data().len() > 4);
691    }
692
693    #[tokio::test]
694    async fn test_encode_without_client_fee_produces_transaction() {
695        let encoder = real_encoder();
696        let quote = make_order_quote()
697            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
698
699        let encoding_options = EncodingOptions::new(0.01);
700
701        let result = encoder
702            .encode(vec![quote], encoding_options)
703            .await
704            .unwrap();
705
706        assert!(result[0].transaction().is_some());
707    }
708
709    // ==================== Signature Offset Tests ====================
710
711    fn make_client_fee(bps: u16) -> crate::ClientFeeParams {
712        crate::ClientFeeParams::new(
713            bps,
714            Bytes::from(make_address(0xBB).as_ref()),
715            BigUint::from(0u64),
716            1_893_456_000u64,
717            Bytes::from(vec![]),
718        )
719    }
720
721    #[tokio::test]
722    async fn test_encode_with_client_fee_returns_signature_offset() {
723        let encoder = real_encoder();
724        let quote = make_order_quote()
725            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
726        let opts = EncodingOptions::new(0.01).with_client_fee_params(make_client_fee(100));
727
728        let result = encoder
729            .encode(vec![quote], opts)
730            .await
731            .unwrap();
732
733        let tx = result[0].transaction().unwrap();
734        tx.client_fee_signature_offset()
735            .expect("client_fee_signature_offset must be present with client fee");
736    }
737
738    #[tokio::test]
739    async fn test_encode_without_client_fee_has_no_signature_offset() {
740        let encoder = real_encoder();
741        let quote = make_order_quote()
742            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
743        let opts = EncodingOptions::new(0.01);
744
745        let result = encoder
746            .encode(vec![quote], opts)
747            .await
748            .unwrap();
749
750        let tx = result[0].transaction().unwrap();
751        assert!(tx
752            .client_fee_signature_offset()
753            .is_none());
754    }
755
756    #[tokio::test]
757    async fn test_signature_offset_allows_patching() {
758        let encoder = real_encoder();
759        let real_sig = vec![0xFF; 65];
760        let quote = make_order_quote()
761            .with_route(make_route_with_tokens(&[(make_address(0x01), make_address(0x02))]));
762        let opts = EncodingOptions::new(0.01).with_client_fee_params(make_client_fee(100));
763
764        let result = encoder
765            .encode(vec![quote], opts)
766            .await
767            .unwrap();
768
769        let tx = result[0].transaction().unwrap();
770        let offset = tx
771            .client_fee_signature_offset()
772            .unwrap();
773
774        let mut calldata = tx.data().to_vec();
775        calldata[offset..offset + 65].copy_from_slice(&real_sig);
776        assert_eq!(&calldata[offset..offset + 65], &real_sig[..]);
777    }
778}