Skip to main content

fynd_client/
types.rs

1use alloy::{
2    primitives::{keccak256, U256},
3    sol_types::SolValue,
4};
5use bytes::Bytes;
6use num_bigint::BigUint;
7
8use crate::{error::FyndError, mapping::biguint_to_u256};
9
10// ============================================================================
11// ENCODING TYPES
12// ============================================================================
13
14/// Token transfer method used when building an on-chain swap transaction.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum UserTransferType {
17    /// Use standard ERC-20 `approve` + `transferFrom`. Default.
18    #[default]
19    TransferFrom,
20    /// Use Permit2 single-token authorization. Requires [`EncodingOptions::with_permit2`].
21    TransferFromPermit2,
22    /// Use funds from the Tycho Router vault (no token transfer performed).
23    UseVaultsFunds,
24}
25
26/// Per-token details for a Permit2 single-token authorization.
27#[derive(Debug, Clone)]
28pub struct PermitDetails {
29    pub(crate) token: bytes::Bytes,
30    pub(crate) amount: num_bigint::BigUint,
31    pub(crate) expiration: num_bigint::BigUint,
32    pub(crate) nonce: num_bigint::BigUint,
33}
34
35impl PermitDetails {
36    /// Construct a Permit2 token details entry.
37    ///
38    /// - `token`: 20-byte ERC-20 token address.
39    /// - `amount`: allowance cap (must fit in `uint160`).
40    /// - `expiration`: Unix timestamp in seconds at which the permit expires (must fit in
41    ///   `uint48`).
42    /// - `nonce`: Permit2 per-token nonce (must fit in `uint48`).
43    pub fn new(
44        token: bytes::Bytes,
45        amount: num_bigint::BigUint,
46        expiration: num_bigint::BigUint,
47        nonce: num_bigint::BigUint,
48    ) -> Self {
49        Self { token, amount, expiration, nonce }
50    }
51}
52
53/// A single Permit2 authorization, covering one token for one spender.
54#[derive(Debug, Clone)]
55pub struct PermitSingle {
56    pub(crate) details: PermitDetails,
57    pub(crate) spender: bytes::Bytes,
58    pub(crate) sig_deadline: num_bigint::BigUint,
59}
60
61impl PermitSingle {
62    /// Construct a single-token Permit2 authorisation.
63    ///
64    /// - `details`: per-token allowance parameters (see [`PermitDetails::new`]).
65    /// - `spender`: 20-byte address authorised to transfer the token.
66    /// - `sig_deadline`: Unix timestamp in seconds at which the signature expires.
67    pub fn new(
68        details: PermitDetails,
69        spender: bytes::Bytes,
70        sig_deadline: num_bigint::BigUint,
71    ) -> Self {
72        Self { details, spender, sig_deadline }
73    }
74
75    /// Compute the Permit2 EIP-712 signing hash for this permit.
76    ///
77    /// Pass the returned bytes to your signer's `sign_hash` method, then supply the
78    /// 65-byte result as the `signature` argument to [`EncodingOptions::with_permit2`].
79    ///
80    /// `permit2_address` must be the 20-byte address of the Permit2 contract
81    /// (canonical cross-chain deployment: `0x000000000022D473030F116dDEE9F6B43aC78BA3`).
82    ///
83    /// # Errors
84    ///
85    /// Returns [`crate::FyndError::Protocol`] if any address field is not exactly 20 bytes,
86    /// or if `amount` / `expiration` / `nonce` exceed their respective Solidity types.
87    pub fn eip712_signing_hash(
88        &self,
89        chain_id: u64,
90        permit2_address: &bytes::Bytes,
91    ) -> Result<[u8; 32], crate::error::FyndError> {
92        use alloy::sol_types::{eip712_domain, SolStruct};
93
94        let permit2_addr = p2_bytes_to_address(permit2_address, "permit2_address")?;
95        let token = p2_bytes_to_address(&self.details.token, "token")?;
96        let spender = p2_bytes_to_address(&self.spender, "spender")?;
97
98        let amount = p2_biguint_to_uint160(&self.details.amount)?;
99        let expiration = p2_biguint_to_uint48(&self.details.expiration)?;
100        let nonce = p2_biguint_to_uint48(&self.details.nonce)?;
101        let sig_deadline = crate::mapping::biguint_to_u256(&self.sig_deadline);
102
103        let domain = eip712_domain! {
104            name: "Permit2",
105            chain_id: chain_id,
106            verifying_contract: permit2_addr,
107        };
108        #[allow(non_snake_case)]
109        let permit = permit2_sol::PermitSingle {
110            details: permit2_sol::PermitDetails { token, amount, expiration, nonce },
111            spender,
112            sigDeadline: sig_deadline,
113        };
114        Ok(permit.eip712_signing_hash(&domain).0)
115    }
116}
117
118/// Fee units per basis point in the router's `ClientFeeParams.clientFeeBps`.
119///
120/// Fynd's API takes the client fee in basis points, while the router takes it in the
121/// FeeCalculator's fee units (`MAX_BPS` = 100,000,000 = 100%). Signatures must cover the
122/// scaled value that ends up in the calldata.
123const CLIENT_FEE_UNITS_PER_BPS: u64 = 10_000;
124
125/// Client fee configuration for the Tycho Router.
126///
127/// When attached to [`EncodingOptions`] via [`EncodingOptions::with_client_fee`], the router
128/// charges a client fee on the swap output. The `signature` must be an EIP-712 signature by the
129/// `receiver` over the `ClientFee` typed data — compute the hash with
130/// [`ClientFeeParams::eip712_signing_hash`].
131#[derive(Debug, Clone)]
132pub struct ClientFeeParams {
133    pub(crate) bps: u16,
134    pub(crate) receiver: Bytes,
135    pub(crate) max_contribution: BigUint,
136    pub(crate) deadline: u64,
137    pub(crate) signature: Option<Bytes>,
138}
139
140impl ClientFeeParams {
141    /// Create client fee params.
142    ///
143    /// `signature` must be a 65-byte EIP-712 signature by `receiver`.
144    pub fn new(bps: u16, receiver: Bytes, max_contribution: BigUint, deadline: u64) -> Self {
145        Self { bps, receiver, max_contribution, deadline, signature: None }
146    }
147
148    /// Set the EIP-712 signature.
149    pub fn with_signature(mut self, signature: Bytes) -> Self {
150        self.signature = Some(signature);
151        self
152    }
153
154    /// Compute the EIP-712 signing hash for the client fee params.
155    ///
156    /// Pass the returned hash to the fee receiver's signer, then supply the
157    /// 65-byte result to [`ClientFeeParams::with_signature`].
158    ///
159    /// The hash covers all 11 `ClientFee` fields. The swap-specific inputs
160    /// (`amount_in`, `token_in`, `token_out`, `expected_amount_out`, `min_amount_out`,
161    /// `receiver`, `swaps_hash`) come from a prior unsigned quote request — see
162    /// [`FeeBreakdown`] and the `swap_client_fee` example for the two-step flow.
163    ///
164    /// - `router_address`: 20-byte address of the TychoRouter contract.
165    /// - `amount_in`: exact input amount from the order.
166    /// - `token_in`: 20-byte input token address.
167    /// - `token_out`: 20-byte output token address.
168    /// - `expected_amount_out`: quoted output amount — use `Quote::amount_out`.
169    /// - `min_amount_out`: minimum output after fees — use [`FeeBreakdown::min_amount_received`].
170    /// - `receiver`: 20-byte address receiving the swap output.
171    /// - `swaps_hash`: keccak256 of the encoded swaps bytes — use [`FeeBreakdown::swaps_hash`].
172    #[allow(clippy::too_many_arguments)]
173    pub fn eip712_signing_hash(
174        &self,
175        chain_id: u64,
176        router_address: &Bytes,
177        amount_in: &num_bigint::BigUint,
178        token_in: &Bytes,
179        token_out: &Bytes,
180        expected_amount_out: &num_bigint::BigUint,
181        min_amount_out: &num_bigint::BigUint,
182        receiver: &Bytes,
183        swaps_hash: &[u8; 32],
184    ) -> Result<[u8; 32], crate::error::FyndError> {
185        let router_addr = p2_bytes_to_address(router_address, "router_address")?;
186        let fee_receiver = p2_bytes_to_address(&self.receiver, "receiver")?;
187        let max_contrib = biguint_to_u256(&self.max_contribution);
188        let dl = U256::from(self.deadline);
189        let amount_in_u256 = biguint_to_u256(amount_in);
190        let token_in_addr = p2_bytes_to_address(token_in, "token_in")?;
191        let token_out_addr = p2_bytes_to_address(token_out, "token_out")?;
192        let expected_amount_out_u256 = biguint_to_u256(expected_amount_out);
193        let min_amount_out_u256 = biguint_to_u256(min_amount_out);
194        let receiver_addr = p2_bytes_to_address(receiver, "receiver")?;
195        let swaps_b256 = alloy::primitives::B256::from(*swaps_hash);
196
197        let type_hash = keccak256(
198            b"ClientFee(uint32 clientFeeBps,address clientFeeReceiver,\
199uint256 maxClientContribution,uint256 deadline,\
200uint256 amountIn,address tokenIn,address tokenOut,\
201uint256 expectedAmountOut,uint256 minAmountOut,address receiver,bytes swaps)",
202        );
203
204        let domain_type_hash = keccak256(
205            b"EIP712Domain(string name,string version,\
206uint256 chainId,address verifyingContract)",
207        );
208        let domain_separator = keccak256(
209            (
210                domain_type_hash,
211                keccak256(b"TychoRouter"),
212                keccak256(b"1"),
213                U256::from(chain_id),
214                router_addr,
215            )
216                .abi_encode(),
217        );
218
219        let struct_hash = keccak256(
220            (
221                type_hash,
222                U256::from(self.bps as u64 * CLIENT_FEE_UNITS_PER_BPS),
223                fee_receiver,
224                max_contrib,
225                dl,
226                amount_in_u256,
227                token_in_addr,
228                token_out_addr,
229                expected_amount_out_u256,
230                min_amount_out_u256,
231                receiver_addr,
232                swaps_b256,
233            )
234                .abi_encode(),
235        );
236
237        let mut data = [0u8; 66];
238        data[0] = 0x19;
239        data[1] = 0x01;
240        data[2..34].copy_from_slice(domain_separator.as_ref());
241        data[34..66].copy_from_slice(struct_hash.as_ref());
242        Ok(keccak256(data).0)
243    }
244}
245
246// ---------------------------------------------------------------------------
247// Private helpers for eip712_signing_hash
248// ---------------------------------------------------------------------------
249
250mod permit2_sol {
251    use alloy::sol;
252
253    sol! {
254        struct PermitDetails {
255            address token;
256            uint160 amount;
257            uint48 expiration;
258            uint48 nonce;
259        }
260        struct PermitSingle {
261            PermitDetails details;
262            address spender;
263            uint256 sigDeadline;
264        }
265    }
266}
267
268fn p2_bytes_to_address(
269    b: &bytes::Bytes,
270    field: &str,
271) -> Result<alloy::primitives::Address, crate::error::FyndError> {
272    let arr: [u8; 20] = b.as_ref().try_into().map_err(|_| {
273        crate::error::FyndError::Protocol(format!(
274            "expected 20-byte address for {field}, got {} bytes",
275            b.len()
276        ))
277    })?;
278    Ok(alloy::primitives::Address::from(arr))
279}
280
281fn p2_biguint_to_uint160(
282    n: &num_bigint::BigUint,
283) -> Result<alloy::primitives::Uint<160, 3>, crate::error::FyndError> {
284    let bytes = n.to_bytes_be();
285    if bytes.len() > 20 {
286        return Err(crate::error::FyndError::Protocol(format!(
287            "permit amount exceeds uint160 ({} bytes)",
288            bytes.len()
289        )));
290    }
291    let mut arr = [0u8; 20];
292    arr[20 - bytes.len()..].copy_from_slice(&bytes);
293    Ok(alloy::primitives::Uint::<160, 3>::from_be_bytes(arr))
294}
295
296fn p2_biguint_to_uint48(
297    n: &num_bigint::BigUint,
298) -> Result<alloy::primitives::Uint<48, 1>, crate::error::FyndError> {
299    let bytes = n.to_bytes_be();
300    if bytes.len() > 6 {
301        return Err(crate::error::FyndError::Protocol(format!(
302            "permit value exceeds uint48 ({} bytes)",
303            bytes.len()
304        )));
305    }
306    let mut arr = [0u8; 6];
307    arr[6 - bytes.len()..].copy_from_slice(&bytes);
308    Ok(alloy::primitives::Uint::<48, 1>::from_be_bytes(arr))
309}
310
311/// Options that instruct the server to return ABI-encoded calldata in the quote response.
312///
313/// Pass via [`QuoteOptions::with_encoding_options`] to opt into calldata generation. Without this,
314/// the server returns routing information only and [`Quote::transaction`] will be `None`.
315#[derive(Debug, Clone)]
316pub struct EncodingOptions {
317    pub(crate) slippage: f64,
318    pub(crate) transfer_type: UserTransferType,
319    pub(crate) permit: Option<PermitSingle>,
320    pub(crate) permit2_signature: Option<Bytes>,
321    pub(crate) client_fee_params: Option<ClientFeeParams>,
322    pub(crate) price_guard: Option<PriceGuardConfig>,
323}
324
325impl EncodingOptions {
326    /// Create encoding options with the given slippage tolerance.
327    ///
328    /// `slippage` is a fraction (e.g. `0.005` for 0.5%). The transfer type defaults to
329    /// [`UserTransferType::TransferFrom`].
330    pub fn new(slippage: f64) -> Self {
331        Self {
332            slippage,
333            transfer_type: UserTransferType::TransferFrom,
334            permit: None,
335            permit2_signature: None,
336            client_fee_params: None,
337            price_guard: None,
338        }
339    }
340
341    /// Enable Permit2 token transfer with a pre-computed EIP-712 signature.
342    ///
343    /// `signature` must be the 65-byte result of signing the Permit2 typed-data hash
344    /// externally (ECDSA: 32-byte r, 32-byte s, 1-byte v).
345    ///
346    /// # Errors
347    ///
348    /// Returns [`crate::FyndError::Protocol`] if `signature` is not exactly 65 bytes.
349    pub fn with_permit2(
350        mut self,
351        permit: PermitSingle,
352        signature: bytes::Bytes,
353    ) -> Result<Self, crate::error::FyndError> {
354        if signature.len() != 65 {
355            return Err(crate::error::FyndError::Protocol(format!(
356                "Permit2 signature must be exactly 65 bytes, got {}",
357                signature.len()
358            )));
359        }
360        self.transfer_type = UserTransferType::TransferFromPermit2;
361        self.permit = Some(permit);
362        self.permit2_signature = Some(signature);
363        Ok(self)
364    }
365
366    /// Use funds from the Tycho Router vault (no token transfer performed).
367    pub fn with_vault_funds(mut self) -> Self {
368        self.transfer_type = UserTransferType::UseVaultsFunds;
369        self
370    }
371
372    /// Attach client fee configuration with a pre-signed EIP-712 signature.
373    pub fn with_client_fee(mut self, params: ClientFeeParams) -> Self {
374        self.client_fee_params = Some(params);
375        self
376    }
377
378    /// Configure price guard tolerance and fallback behavior for this request.
379    ///
380    /// Fields left as `None` in [`PriceGuardConfig`] use struct defaults.
381    pub fn with_price_guard(mut self, config: PriceGuardConfig) -> Self {
382        self.price_guard = Some(config);
383        self
384    }
385}
386
387/// An encoded EVM transaction returned by the server when [`EncodingOptions`] was set.
388///
389/// Contains everything needed to submit the swap on-chain.
390#[derive(Debug, Clone)]
391pub struct Transaction {
392    to: Bytes,
393    value: BigUint,
394    pub(crate) data: Vec<u8>,
395    pub(crate) client_fee_signature_offset: Option<usize>,
396}
397
398impl Transaction {
399    /// Create a new transaction from the given parameters.
400    ///
401    /// - `to`: 20-byte contract address to call.
402    /// - `value`: native token value to send with the transaction.
403    /// - `data`: ABI-encoded calldata.
404    pub fn new(to: Bytes, value: BigUint, data: Vec<u8>) -> Self {
405        Self { to, value, data, client_fee_signature_offset: None }
406    }
407
408    /// Router contract address (20 raw bytes).
409    pub fn to(&self) -> &Bytes {
410        &self.to
411    }
412
413    /// Native value to send with the transaction (token units; usually `0` for ERC-20 swaps).
414    pub fn value(&self) -> &BigUint {
415        &self.value
416    }
417
418    /// ABI-encoded calldata.
419    pub fn data(&self) -> &[u8] {
420        &self.data
421    }
422
423    /// Byte offset of the client fee signature within `data`.
424    pub fn client_fee_signature_offset(&self) -> Option<usize> {
425        self.client_fee_signature_offset
426    }
427}
428
429// ============================================================================
430// ORDER SIDE
431// ============================================================================
432
433/// The direction of a swap order.
434///
435/// Currently only [`Sell`](Self::Sell) (exact-input) is supported.
436#[non_exhaustive]
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum OrderSide {
439    /// Sell exactly the specified `amount` of `token_in` for as much `token_out` as possible.
440    Sell,
441}
442
443// ============================================================================
444// REQUEST TYPES
445// ============================================================================
446
447/// A single swap intent submitted to the Fynd solver.
448///
449/// Addresses are raw 20-byte values (`bytes::Bytes`). The amount is denominated
450/// in the smallest unit of the input token (e.g. wei for ETH, atomic units for ERC-20).
451#[derive(Debug, Clone)]
452pub struct Order {
453    token_in: Bytes,
454    token_out: Bytes,
455    amount: BigUint,
456    side: OrderSide,
457    sender: Bytes,
458    receiver: Option<Bytes>,
459}
460
461impl Order {
462    /// Construct a new order.
463    ///
464    /// - `token_in`: 20-byte ERC-20 address of the token to sell.
465    /// - `token_out`: 20-byte ERC-20 address of the token to receive.
466    /// - `amount`: exact amount to sell (token units, not wei unless the token is WETH).
467    /// - `side`: must be [`OrderSide::Sell`]; buy orders are not yet supported.
468    /// - `sender`: 20-byte address of the wallet sending `token_in`.
469    /// - `receiver`: 20-byte address that receives `token_out`. Defaults to `sender` if `None`.
470    pub fn new(
471        token_in: Bytes,
472        token_out: Bytes,
473        amount: BigUint,
474        side: OrderSide,
475        sender: Bytes,
476        receiver: Option<Bytes>,
477    ) -> Self {
478        Self { token_in, token_out, amount, side, sender, receiver }
479    }
480
481    /// The address of the token being sold (20 raw bytes).
482    pub fn token_in(&self) -> &Bytes {
483        &self.token_in
484    }
485
486    /// The address of the token being bought (20 raw bytes).
487    pub fn token_out(&self) -> &Bytes {
488        &self.token_out
489    }
490
491    /// The amount to sell, in token units.
492    pub fn amount(&self) -> &BigUint {
493        &self.amount
494    }
495
496    /// Whether this is a sell (exact-input) or buy (exact-output) order.
497    pub fn side(&self) -> OrderSide {
498        self.side
499    }
500
501    /// The address that will send `token_in` (20 raw bytes).
502    pub fn sender(&self) -> &Bytes {
503        &self.sender
504    }
505
506    /// The address that will receive `token_out` (20 raw bytes), or `None` if it defaults to
507    /// [`sender`](Self::sender).
508    pub fn receiver(&self) -> Option<&Bytes> {
509        self.receiver.as_ref()
510    }
511}
512
513/// Per-request price guard configuration.
514///
515/// All fields are optional. When `None`, struct defaults are used.
516/// Re-exported from `fynd-rpc-types` for wire compatibility.
517pub use fynd_rpc_types::PriceGuardConfig;
518
519/// Optional parameters that tune solving behaviour for a [`QuoteParams`] request.
520///
521/// Build via the builder methods; unset options use server defaults.
522#[derive(Debug, Clone, Default)]
523pub struct QuoteOptions {
524    pub(crate) timeout_ms: Option<u64>,
525    pub(crate) min_responses: Option<usize>,
526    pub(crate) max_gas: Option<BigUint>,
527    pub(crate) encoding_options: Option<EncodingOptions>,
528}
529
530impl QuoteOptions {
531    /// Cap the solver's wall-clock budget to `ms` milliseconds.
532    pub fn with_timeout_ms(mut self, ms: u64) -> Self {
533        self.timeout_ms = Some(ms);
534        self
535    }
536
537    /// Return as soon as at least `n` solver pools have responded, rather than waiting for all.
538    ///
539    /// Use [`HealthStatus::num_solver_pools`] to discover how many pools are active before
540    /// setting this value. Values exceeding the active pool count are clamped by the server.
541    pub fn with_min_responses(mut self, n: usize) -> Self {
542        self.min_responses = Some(n);
543        self
544    }
545
546    /// Discard quotes whose estimated gas cost exceeds `gas`.
547    pub fn with_max_gas(mut self, gas: BigUint) -> Self {
548        self.max_gas = Some(gas);
549        self
550    }
551
552    /// Request server-side calldata generation. The resulting [`Quote::transaction`] will be
553    /// populated when this option is set.
554    pub fn with_encoding_options(mut self, opts: EncodingOptions) -> Self {
555        self.encoding_options = Some(opts);
556        self
557    }
558
559    /// The configured timeout in milliseconds, or `None` if using the server default.
560    pub fn timeout_ms(&self) -> Option<u64> {
561        self.timeout_ms
562    }
563
564    /// The configured minimum response count, or `None` if using the server default.
565    pub fn min_responses(&self) -> Option<usize> {
566        self.min_responses
567    }
568
569    /// The configured gas cap, or `None` if no cap was set.
570    pub fn max_gas(&self) -> Option<&BigUint> {
571        self.max_gas.as_ref()
572    }
573}
574
575/// All inputs needed to call [`FyndClient::quote`](crate::FyndClient::quote).
576#[derive(Debug, Clone)]
577pub struct QuoteParams {
578    pub(crate) order: Order,
579    pub(crate) options: QuoteOptions,
580}
581
582impl QuoteParams {
583    /// Create a new request from a list of orders and optional solver options.
584    pub fn new(order: Order, options: QuoteOptions) -> Self {
585        Self { order, options }
586    }
587}
588
589/// All inputs needed to call [`FyndClient::batch_quote`](crate::FyndClient::batch_quote).
590///
591/// Submits multiple orders in a single request. All orders share the same [`QuoteOptions`].
592/// The response preserves the input order: `quotes[i]` corresponds to `orders[i]`.
593#[derive(Debug, Clone)]
594pub struct BatchQuoteParams {
595    pub(crate) orders: Vec<Order>,
596    pub(crate) options: QuoteOptions,
597}
598
599impl BatchQuoteParams {
600    /// Create a batch request from a list of orders and shared solving options.
601    ///
602    /// `orders` must be non-empty. Each order is solved independently by the server;
603    /// the response vec has the same length and index alignment as the input.
604    pub fn new(orders: Vec<Order>, options: QuoteOptions) -> Self {
605        Self { orders, options }
606    }
607}
608
609// ============================================================================
610// RESPONSE TYPES
611// ============================================================================
612
613/// Which backend solver produced a given order quote.
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum BackendKind {
616    /// The native Fynd solver.
617    Fynd,
618    /// The Turbine solver (integration in progress).
619    Turbine,
620}
621
622/// High-level status of a single-order quote returned by the solver.
623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
624pub enum QuoteStatus {
625    /// A valid route was found and `route`, `amount_out`, and `gas_estimate` are populated.
626    Success,
627    /// No swap path exists between the requested token pair on available swap components (pools).
628    NoRouteFound,
629    /// A path exists but available liquidity is too low for the requested amount.
630    InsufficientLiquidity,
631    /// The solver timed out before finding a route.
632    Timeout,
633    /// No solver workers are initialised yet (e.g. market data not loaded).
634    NotReady,
635    /// The solution failed external price validation.
636    PriceCheckFailed,
637}
638
639/// Ethereum block at which a quote was computed.
640///
641/// Quotes are only valid for the block at which they were produced. Conditions may have changed
642/// by the time you submit the transaction.
643#[derive(Debug, Clone)]
644pub struct BlockInfo {
645    number: u64,
646    hash: String,
647    timestamp: u64,
648}
649
650impl BlockInfo {
651    /// The block number.
652    pub fn number(&self) -> u64 {
653        self.number
654    }
655
656    /// The block hash as a hex string (e.g. `"0xabcd..."`).
657    pub fn hash(&self) -> &str {
658        &self.hash
659    }
660
661    /// The block timestamp in Unix seconds.
662    pub fn timestamp(&self) -> u64 {
663        self.timestamp
664    }
665
666    /// Create a new [`BlockInfo`].
667    pub fn new(number: u64, hash: String, timestamp: u64) -> Self {
668        Self { number, hash, timestamp }
669    }
670}
671
672/// A single atomic swap on one component (liquidity pool) within a [`Route`].
673#[derive(Debug, Clone)]
674pub struct Swap {
675    component_id: String,
676    protocol: String,
677    token_in: Bytes,
678    token_out: Bytes,
679    amount_in: BigUint,
680    amount_out: BigUint,
681    gas_estimate: BigUint,
682    #[allow(dead_code)]
683    split: f64,
684}
685
686impl Swap {
687    /// The identifier of the component (e.g. a liquidity pool address).
688    pub fn component_id(&self) -> &str {
689        &self.component_id
690    }
691
692    /// The protocol identifier (e.g. `"uniswap_v3"`, `"vm:balancer"`).
693    pub fn protocol(&self) -> &str {
694        &self.protocol
695    }
696
697    /// Input token address (20 raw bytes).
698    pub fn token_in(&self) -> &Bytes {
699        &self.token_in
700    }
701
702    /// Output token address (20 raw bytes).
703    pub fn token_out(&self) -> &Bytes {
704        &self.token_out
705    }
706
707    /// Amount of `token_in` consumed by this swap (token units).
708    pub fn amount_in(&self) -> &BigUint {
709        &self.amount_in
710    }
711
712    /// Amount of `token_out` produced by this swap (token units).
713    pub fn amount_out(&self) -> &BigUint {
714        &self.amount_out
715    }
716
717    /// Estimated gas units required to execute this swap.
718    pub fn gas_estimate(&self) -> &BigUint {
719        &self.gas_estimate
720    }
721
722    /// Create a new [`Swap`].
723    #[allow(clippy::too_many_arguments)]
724    pub fn new(
725        component_id: String,
726        protocol: String,
727        token_in: Bytes,
728        token_out: Bytes,
729        amount_in: BigUint,
730        amount_out: BigUint,
731        gas_estimate: BigUint,
732        split: f64,
733    ) -> Self {
734        Self {
735            component_id,
736            protocol,
737            token_in,
738            token_out,
739            amount_in,
740            amount_out,
741            gas_estimate,
742            split,
743        }
744    }
745}
746
747/// An ordered sequence of swaps that together execute a complete token swap.
748///
749/// For multi-hop routes the output of each [`Swap`] is the input of the next.
750#[derive(Debug, Clone)]
751pub struct Route {
752    swaps: Vec<Swap>,
753}
754
755impl Route {
756    /// The ordered sequence of swaps to execute.
757    pub fn swaps(&self) -> &[Swap] {
758        &self.swaps
759    }
760
761    /// Create a new [`Route`] from a list of swaps.
762    pub fn new(swaps: Vec<Swap>) -> Self {
763        Self { swaps }
764    }
765}
766
767/// Breakdown of fees applied to the swap output by the on-chain FeeCalculator.
768///
769/// All amounts are absolute values in output token units.
770#[derive(Debug, Clone)]
771pub struct FeeBreakdown {
772    router_fee: BigUint,
773    client_fee: BigUint,
774    max_slippage: BigUint,
775    min_amount_received: BigUint,
776    /// keccak256 of the ABI-encoded swap bytes. Use this for EIP-712 signing.
777    swaps_hash: Option<[u8; 32]>,
778}
779
780impl FeeBreakdown {
781    pub(crate) fn new(
782        router_fee: BigUint,
783        client_fee: BigUint,
784        max_slippage: BigUint,
785        min_amount_received: BigUint,
786        swaps_hash: Option<[u8; 32]>,
787    ) -> Self {
788        Self { router_fee, client_fee, max_slippage, min_amount_received, swaps_hash }
789    }
790
791    /// Router protocol fee (fee on output + router's share of client fee).
792    pub fn router_fee(&self) -> &BigUint {
793        &self.router_fee
794    }
795
796    /// Client's portion of the fee (after the router takes its share).
797    pub fn client_fee(&self) -> &BigUint {
798        &self.client_fee
799    }
800
801    /// Maximum slippage: (amount_out - router_fee - client_fee) * slippage.
802    pub fn max_slippage(&self) -> &BigUint {
803        &self.max_slippage
804    }
805
806    /// Minimum amount the user receives on-chain.
807    /// Equal to amount_out - router_fee - client_fee - max_slippage.
808    pub fn min_amount_received(&self) -> &BigUint {
809        &self.min_amount_received
810    }
811
812    /// keccak256 of the ABI-encoded swap bytes.
813    ///
814    /// Use this together with `amount_in`, `token_in`, `token_out`,
815    /// `min_amount_received`, and `receiver` to call
816    /// [`ClientFeeParams::eip712_signing_hash`] in the two-step signing flow.
817    pub fn swaps_hash(&self) -> Option<&[u8; 32]> {
818        self.swaps_hash.as_ref()
819    }
820}
821
822/// The solver's response for a single order.
823#[derive(Debug, Clone)]
824pub struct Quote {
825    order_id: String,
826    status: QuoteStatus,
827    backend: BackendKind,
828    route: Option<Route>,
829    amount_in: BigUint,
830    amount_out: BigUint,
831    gas_estimate: BigUint,
832    amount_out_net_gas: BigUint,
833    price_impact_bps: Option<i32>,
834    block: BlockInfo,
835    /// Output token address from the original order (20 raw bytes).
836    /// Populated by `quote()` from the corresponding `Order`.
837    token_out: Bytes,
838    /// Receiver address from the original order (20 raw bytes).
839    /// Defaults to `sender` if the order had no explicit receiver.
840    /// Populated by `quote()` from the corresponding `Order`.
841    receiver: Bytes,
842    /// ABI-encoded on-chain transaction. Present only when [`EncodingOptions`] was set in the
843    /// request via [`QuoteOptions::with_encoding_options`].
844    transaction: Option<Transaction>,
845    /// Fee breakdown. Present only when [`EncodingOptions`] was set in the request.
846    fee_breakdown: Option<FeeBreakdown>,
847    /// Wall-clock time the server spent solving this request, in milliseconds.
848    /// Populated by [`FyndClient::quote`](crate::FyndClient::quote).
849    pub(crate) solve_time_ms: u64,
850}
851
852impl Quote {
853    /// The server-assigned order ID (UUID v4).
854    pub fn order_id(&self) -> &str {
855        &self.order_id
856    }
857
858    /// Whether the solver found a valid route for this order.
859    pub fn status(&self) -> QuoteStatus {
860        self.status
861    }
862
863    /// Which backend produced this quote.
864    pub fn backend(&self) -> BackendKind {
865        self.backend
866    }
867
868    /// The route to execute, if [`status`](Self::status) is [`QuoteStatus::Success`].
869    pub fn route(&self) -> Option<&Route> {
870        self.route.as_ref()
871    }
872
873    /// The amount of `token_in` the solver expects to consume (token units).
874    pub fn amount_in(&self) -> &BigUint {
875        &self.amount_in
876    }
877
878    /// The expected amount of `token_out` received after executing the route (token units).
879    pub fn amount_out(&self) -> &BigUint {
880        &self.amount_out
881    }
882
883    /// Estimated gas units required to execute the entire route.
884    pub fn gas_estimate(&self) -> &BigUint {
885        &self.gas_estimate
886    }
887
888    /// Amount out minus estimated gas cost, expressed in output token units.
889    ///
890    /// Computed server-side using the current gas price and the quote's implied
891    /// exchange rate. This is the primary metric the solver uses to rank routes.
892    pub fn amount_out_net_gas(&self) -> &BigUint {
893        &self.amount_out_net_gas
894    }
895
896    /// Price impact in basis points (1 bps = 0.01%). May be `None` for quotes without a route.
897    pub fn price_impact_bps(&self) -> Option<i32> {
898        self.price_impact_bps
899    }
900
901    /// The Ethereum block at which this quote was computed.
902    pub fn block(&self) -> &BlockInfo {
903        &self.block
904    }
905
906    /// The `token_out` address from the originating [`Order`] (20 raw bytes).
907    ///
908    /// Populated by [`FyndClient::quote`](crate::FyndClient::quote) and used by
909    /// [`FyndClient::execute_swap`](crate::FyndClient::execute_swap) to parse the settlement log.
910    pub fn token_out(&self) -> &Bytes {
911        &self.token_out
912    }
913
914    /// The receiver address from the originating [`Order`] (20 raw bytes).
915    ///
916    /// Defaults to `sender` when the order had no explicit receiver. Populated by
917    /// [`FyndClient::quote`](crate::FyndClient::quote) and used by
918    /// [`FyndClient::execute_swap`](crate::FyndClient::execute_swap) to verify the Transfer log
919    /// recipient.
920    pub fn receiver(&self) -> &Bytes {
921        &self.receiver
922    }
923
924    /// The server-encoded on-chain transaction, present when [`EncodingOptions`] was set.
925    ///
926    /// Contains the router contract address, native value, and ABI-encoded calldata ready to
927    /// submit. Returns `None` when no [`EncodingOptions`] were passed in the request.
928    pub fn transaction(&self) -> Option<&Transaction> {
929        self.transaction.as_ref()
930    }
931
932    /// Fee breakdown, present when [`EncodingOptions`] was set in the request.
933    ///
934    /// Contains router fee, client fee, max slippage, and the minimum amount the user
935    /// will receive on-chain (the value used as `min_amount_out` in the transaction).
936    pub fn fee_breakdown(&self) -> Option<&FeeBreakdown> {
937        self.fee_breakdown.as_ref()
938    }
939
940    /// Wall-clock time the server spent solving this request, in milliseconds.
941    ///
942    /// Populated by [`FyndClient::quote`](crate::FyndClient::quote). Returns `0` if not set.
943    pub fn solve_time_ms(&self) -> u64 {
944        self.solve_time_ms
945    }
946
947    /// Patches the 65-byte client fee EIP-712 signature into the transaction
948    /// calldata at the offset returned by the server.
949    ///
950    /// Use this after a single quote request:
951    ///
952    /// 1. Request a quote with unsigned [`ClientFeeParams`] (empty signature).
953    /// 2. Read [`FeeBreakdown::swaps_hash`] from the response.
954    /// 3. Sign the 11-field EIP-712 hash using [`ClientFeeParams::eip712_signing_hash`].
955    /// 4. Call this method to patch the signature into the calldata.
956    /// 5. Execute the transaction.
957    ///
958    /// # Errors
959    ///
960    /// Returns [`FyndError::Protocol`] if the quote has no transaction or no
961    /// `client_fee_signature_offset`.
962    pub fn with_client_fee_signature(mut self, signature: &[u8]) -> Result<Self, FyndError> {
963        let tx = self
964            .transaction
965            .as_mut()
966            .ok_or_else(|| {
967                FyndError::Protocol("transaction required for signature patching".into())
968            })?;
969        let offset = tx
970            .client_fee_signature_offset()
971            .ok_or_else(|| {
972                FyndError::Protocol(
973                    "client_fee_signature_offset required for signature patching".into(),
974                )
975            })?;
976        tx.data[offset..offset + signature.len()].copy_from_slice(signature);
977        Ok(self)
978    }
979
980    /// Create a new [`Quote`].
981    #[allow(clippy::too_many_arguments)]
982    pub fn new(
983        order_id: String,
984        status: QuoteStatus,
985        backend: BackendKind,
986        route: Option<Route>,
987        amount_in: BigUint,
988        amount_out: BigUint,
989        gas_estimate: BigUint,
990        amount_out_net_gas: BigUint,
991        price_impact_bps: Option<i32>,
992        block: BlockInfo,
993        token_out: Bytes,
994        receiver: Bytes,
995        transaction: Option<Transaction>,
996        fee_breakdown: Option<FeeBreakdown>,
997    ) -> Self {
998        Self {
999            order_id,
1000            status,
1001            backend,
1002            route,
1003            amount_in,
1004            amount_out,
1005            gas_estimate,
1006            amount_out_net_gas,
1007            price_impact_bps,
1008            block,
1009            token_out,
1010            receiver,
1011            transaction,
1012            fee_breakdown,
1013            solve_time_ms: 0,
1014        }
1015    }
1016}
1017
1018/// Static metadata about this Fynd instance, returned by `GET /v1/info`.
1019#[derive(Debug, Clone)]
1020pub struct InstanceInfo {
1021    /// Router contract address (20 raw bytes), or `None` on a quote-only chain.
1022    router_address: Option<bytes::Bytes>,
1023    /// Permit2 contract address (20 raw bytes).
1024    permit2_address: bytes::Bytes,
1025    /// Chain ID of the network this instance is deployed on.
1026    chain_id: u64,
1027    /// Fynd binary version reported by the server.
1028    version: String,
1029}
1030
1031impl InstanceInfo {
1032    pub(crate) fn new(
1033        router_address: Option<bytes::Bytes>,
1034        permit2_address: bytes::Bytes,
1035        chain_id: u64,
1036        version: String,
1037    ) -> Self {
1038        Self { router_address, permit2_address, chain_id, version }
1039    }
1040
1041    /// Router contract address (20 raw bytes), or `None` on a quote-only chain.
1042    pub fn router_address(&self) -> Option<&bytes::Bytes> {
1043        self.router_address.as_ref()
1044    }
1045
1046    /// Permit2 contract address (20 raw bytes).
1047    pub fn permit2_address(&self) -> &bytes::Bytes {
1048        &self.permit2_address
1049    }
1050
1051    /// Chain ID of the network this instance is deployed on.
1052    pub fn chain_id(&self) -> u64 {
1053        self.chain_id
1054    }
1055
1056    /// Fynd binary version reported by the server.
1057    pub fn version(&self) -> &str {
1058        &self.version
1059    }
1060}
1061
1062/// Health information from the Fynd RPC server's `/v1/health` endpoint.
1063#[derive(Debug, Clone)]
1064pub struct HealthStatus {
1065    healthy: bool,
1066    last_update_ms: u64,
1067    num_solver_pools: usize,
1068    derived_data_ready: bool,
1069    gas_price_age_ms: Option<u64>,
1070}
1071
1072impl HealthStatus {
1073    /// `true` when the server has up-to-date market data and active solver pools.
1074    pub fn healthy(&self) -> bool {
1075        self.healthy
1076    }
1077
1078    /// Milliseconds since the last market-data update. High values indicate stale data.
1079    pub fn last_update_ms(&self) -> u64 {
1080        self.last_update_ms
1081    }
1082
1083    /// Number of active solver pool workers. Use this to set `QuoteOptions::with_min_responses`.
1084    pub fn num_solver_pools(&self) -> usize {
1085        self.num_solver_pools
1086    }
1087
1088    /// Whether derived data has been computed at least once.
1089    ///
1090    /// This indicates overall readiness, not per-block freshness. Some algorithms
1091    /// require fresh derived data for each block — they are ready to receive orders
1092    /// but will wait for recomputation before solving.
1093    pub fn derived_data_ready(&self) -> bool {
1094        self.derived_data_ready
1095    }
1096
1097    /// Time since last gas price update in milliseconds, if available.
1098    pub fn gas_price_age_ms(&self) -> Option<u64> {
1099        self.gas_price_age_ms
1100    }
1101
1102    pub(crate) fn new(
1103        healthy: bool,
1104        last_update_ms: u64,
1105        num_solver_pools: usize,
1106        derived_data_ready: bool,
1107        gas_price_age_ms: Option<u64>,
1108    ) -> Self {
1109        Self { healthy, last_update_ms, num_solver_pools, derived_data_ready, gas_price_age_ms }
1110    }
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use num_bigint::BigUint;
1116
1117    use super::*;
1118
1119    fn addr(bytes: &[u8; 20]) -> Bytes {
1120        Bytes::copy_from_slice(bytes)
1121    }
1122
1123    #[test]
1124    fn order_new_and_getters() {
1125        let token_in = addr(&[0xaa; 20]);
1126        let token_out = addr(&[0xbb; 20]);
1127        let amount = BigUint::from(1_000_000u64);
1128        let sender = addr(&[0xcc; 20]);
1129
1130        let order = Order::new(
1131            token_in.clone(),
1132            token_out.clone(),
1133            amount.clone(),
1134            OrderSide::Sell,
1135            sender.clone(),
1136            None,
1137        );
1138
1139        assert_eq!(order.token_in(), &token_in);
1140        assert_eq!(order.token_out(), &token_out);
1141        assert_eq!(order.amount(), &amount);
1142        assert_eq!(order.sender(), &sender);
1143        assert!(order.receiver().is_none());
1144        assert_eq!(order.side(), OrderSide::Sell);
1145    }
1146
1147    #[test]
1148    fn order_with_explicit_receiver() {
1149        let receiver = Bytes::copy_from_slice(&[0xdd; 20]);
1150        let order = Order::new(
1151            Bytes::copy_from_slice(&[0xaa; 20]),
1152            Bytes::copy_from_slice(&[0xbb; 20]),
1153            BigUint::from(1u32),
1154            OrderSide::Sell,
1155            Bytes::copy_from_slice(&[0xcc; 20]),
1156            Some(receiver.clone()),
1157        );
1158        assert_eq!(order.receiver(), Some(&receiver));
1159    }
1160
1161    #[test]
1162    fn quote_options_builder() {
1163        let opts = QuoteOptions::default()
1164            .with_timeout_ms(500)
1165            .with_min_responses(2)
1166            .with_max_gas(BigUint::from(1_000_000u64));
1167
1168        assert_eq!(opts.timeout_ms(), Some(500));
1169        assert_eq!(opts.min_responses(), Some(2));
1170        assert_eq!(opts.max_gas(), Some(&BigUint::from(1_000_000u64)));
1171    }
1172
1173    #[test]
1174    fn quote_options_default_all_none() {
1175        let opts = QuoteOptions::default();
1176        assert!(opts.timeout_ms().is_none());
1177        assert!(opts.min_responses().is_none());
1178        assert!(opts.max_gas().is_none());
1179    }
1180
1181    #[test]
1182    fn encoding_options_with_permit2_sets_fields() {
1183        let token = Bytes::copy_from_slice(&[0xaa; 20]);
1184        let spender = Bytes::copy_from_slice(&[0xbb; 20]);
1185        let sig = Bytes::copy_from_slice(&[0xcc; 65]);
1186        let details = PermitDetails::new(
1187            token,
1188            BigUint::from(1_000u32),
1189            BigUint::from(9_999_999u32),
1190            BigUint::from(0u32),
1191        );
1192        let permit = PermitSingle::new(details, spender, BigUint::from(9_999_999u32));
1193
1194        let opts = EncodingOptions::new(0.005)
1195            .with_permit2(permit, sig.clone())
1196            .unwrap();
1197
1198        assert_eq!(opts.transfer_type, UserTransferType::TransferFromPermit2);
1199        assert!(opts.permit.is_some());
1200        assert_eq!(opts.permit2_signature.as_ref().unwrap(), &sig);
1201    }
1202
1203    #[test]
1204    fn encoding_options_with_permit2_rejects_wrong_signature_length() {
1205        let details = PermitDetails::new(
1206            Bytes::copy_from_slice(&[0xaa; 20]),
1207            BigUint::from(1_000u32),
1208            BigUint::from(9_999_999u32),
1209            BigUint::from(0u32),
1210        );
1211        let permit = PermitSingle::new(
1212            details,
1213            Bytes::copy_from_slice(&[0xbb; 20]),
1214            BigUint::from(9_999_999u32),
1215        );
1216        let bad_sig = Bytes::copy_from_slice(&[0xcc; 64]); // 64 bytes, not 65
1217        assert!(matches!(
1218            EncodingOptions::new(0.005).with_permit2(permit, bad_sig),
1219            Err(crate::error::FyndError::Protocol(_))
1220        ));
1221    }
1222
1223    #[test]
1224    fn encoding_options_with_vault_funds_sets_variant() {
1225        let opts = EncodingOptions::new(0.005).with_vault_funds();
1226        assert_eq!(opts.transfer_type, UserTransferType::UseVaultsFunds);
1227        assert!(opts.permit.is_none());
1228        assert!(opts.permit2_signature.is_none());
1229    }
1230
1231    fn sample_permit_single() -> PermitSingle {
1232        let details = PermitDetails::new(
1233            Bytes::copy_from_slice(&[0xaa; 20]),
1234            BigUint::from(1_000u32),
1235            BigUint::from(9_999_999u32),
1236            BigUint::from(0u32),
1237        );
1238        PermitSingle::new(details, Bytes::copy_from_slice(&[0xbb; 20]), BigUint::from(9_999_999u32))
1239    }
1240
1241    #[test]
1242    fn eip712_signing_hash_returns_32_bytes() {
1243        let permit = sample_permit_single();
1244        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1245        let hash = permit
1246            .eip712_signing_hash(1, &permit2_addr)
1247            .unwrap();
1248        assert_eq!(hash.len(), 32);
1249        // Non-zero: alloy should never hash to all-zeros for a real input
1250        assert_ne!(hash, [0u8; 32]);
1251    }
1252
1253    #[test]
1254    fn eip712_signing_hash_is_deterministic() {
1255        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1256        let h1 = sample_permit_single()
1257            .eip712_signing_hash(1, &permit2_addr)
1258            .unwrap();
1259        let h2 = sample_permit_single()
1260            .eip712_signing_hash(1, &permit2_addr)
1261            .unwrap();
1262        assert_eq!(h1, h2);
1263    }
1264
1265    #[test]
1266    fn eip712_signing_hash_differs_by_chain_id() {
1267        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1268        let h1 = sample_permit_single()
1269            .eip712_signing_hash(1, &permit2_addr)
1270            .unwrap();
1271        let h137 = sample_permit_single()
1272            .eip712_signing_hash(137, &permit2_addr)
1273            .unwrap();
1274        assert_ne!(h1, h137);
1275    }
1276
1277    #[test]
1278    fn eip712_signing_hash_invalid_permit2_address() {
1279        let permit = sample_permit_single();
1280        let bad_addr = Bytes::copy_from_slice(&[0xcc; 4]);
1281        assert!(matches!(
1282            permit.eip712_signing_hash(1, &bad_addr),
1283            Err(crate::error::FyndError::Protocol(_))
1284        ));
1285    }
1286
1287    #[test]
1288    fn eip712_signing_hash_invalid_token_address() {
1289        let details = PermitDetails::new(
1290            Bytes::copy_from_slice(&[0xaa; 4]), // wrong length
1291            BigUint::from(1u32),
1292            BigUint::from(1u32),
1293            BigUint::from(0u32),
1294        );
1295        let permit =
1296            PermitSingle::new(details, Bytes::copy_from_slice(&[0xbb; 20]), BigUint::from(1u32));
1297        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1298        assert!(matches!(
1299            permit.eip712_signing_hash(1, &permit2_addr),
1300            Err(crate::error::FyndError::Protocol(_))
1301        ));
1302    }
1303
1304    #[test]
1305    fn eip712_signing_hash_amount_exceeds_uint160() {
1306        // 21 bytes > 20 bytes (uint160 = 160 bits = 20 bytes)
1307        let oversized_amount = BigUint::from_bytes_be(&[0x01; 21]);
1308        let details = PermitDetails::new(
1309            Bytes::copy_from_slice(&[0xaa; 20]),
1310            oversized_amount,
1311            BigUint::from(1u32),
1312            BigUint::from(0u32),
1313        );
1314        let permit =
1315            PermitSingle::new(details, Bytes::copy_from_slice(&[0xbb; 20]), BigUint::from(1u32));
1316        let permit2_addr = Bytes::copy_from_slice(&[0xcc; 20]);
1317        assert!(matches!(
1318            permit.eip712_signing_hash(1, &permit2_addr),
1319            Err(crate::error::FyndError::Protocol(_))
1320        ));
1321    }
1322
1323    // -------------------------------------------------------------------------
1324    // ClientFeeParams Tests
1325    // -------------------------------------------------------------------------
1326
1327    fn sample_fee_receiver() -> Bytes {
1328        Bytes::copy_from_slice(&[0x44; 20])
1329    }
1330
1331    fn sample_router_address() -> Bytes {
1332        Bytes::copy_from_slice(&[0x33; 20])
1333    }
1334
1335    fn sample_fee_params(bps: u16, receiver: Bytes) -> ClientFeeParams {
1336        ClientFeeParams::new(bps, receiver, BigUint::ZERO, 1_893_456_000)
1337    }
1338
1339    fn sample_token_in() -> Bytes {
1340        Bytes::copy_from_slice(&[0x11; 20])
1341    }
1342
1343    fn sample_token_out() -> Bytes {
1344        Bytes::copy_from_slice(&[0x22; 20])
1345    }
1346
1347    fn sample_swap_receiver() -> Bytes {
1348        Bytes::copy_from_slice(&[0xAA; 20])
1349    }
1350
1351    fn sample_min_amount_out() -> BigUint {
1352        BigUint::from(1_000_000u64)
1353    }
1354
1355    fn sample_expected_amount_out() -> BigUint {
1356        BigUint::from(1_010_000u64)
1357    }
1358
1359    fn sample_amount_in() -> BigUint {
1360        BigUint::from(1_000_000_000_000_000_000u64)
1361    }
1362
1363    fn sample_swaps_hash() -> [u8; 32] {
1364        [0xAB; 32]
1365    }
1366
1367    #[test]
1368    fn client_fee_with_client_fee_sets_fields() {
1369        let fee = ClientFeeParams::new(
1370            100,
1371            sample_fee_receiver(),
1372            BigUint::from(500_000u64),
1373            1_893_456_000,
1374        );
1375        let opts = EncodingOptions::new(0.01).with_client_fee(fee);
1376        assert!(opts.client_fee_params.is_some());
1377        let stored = opts.client_fee_params.as_ref().unwrap();
1378        assert_eq!(stored.bps, 100);
1379        assert_eq!(stored.max_contribution, BigUint::from(500_000u64));
1380    }
1381
1382    #[test]
1383    fn client_fee_signing_hash_returns_32_bytes() {
1384        let fee = sample_fee_params(100, sample_fee_receiver());
1385        let hash = fee
1386            .eip712_signing_hash(
1387                1,
1388                &sample_router_address(),
1389                &sample_amount_in(),
1390                &sample_token_in(),
1391                &sample_token_out(),
1392                &sample_expected_amount_out(),
1393                &sample_min_amount_out(),
1394                &sample_swap_receiver(),
1395                &sample_swaps_hash(),
1396            )
1397            .unwrap();
1398        assert_eq!(hash.len(), 32);
1399        assert_ne!(hash, [0u8; 32]);
1400    }
1401
1402    #[test]
1403    fn client_fee_signing_hash_is_deterministic() {
1404        let fee = sample_fee_params(100, sample_fee_receiver());
1405        let h1 = fee
1406            .eip712_signing_hash(
1407                1,
1408                &sample_router_address(),
1409                &sample_amount_in(),
1410                &sample_token_in(),
1411                &sample_token_out(),
1412                &sample_expected_amount_out(),
1413                &sample_min_amount_out(),
1414                &sample_swap_receiver(),
1415                &sample_swaps_hash(),
1416            )
1417            .unwrap();
1418        let h2 = fee
1419            .eip712_signing_hash(
1420                1,
1421                &sample_router_address(),
1422                &sample_amount_in(),
1423                &sample_token_in(),
1424                &sample_token_out(),
1425                &sample_expected_amount_out(),
1426                &sample_min_amount_out(),
1427                &sample_swap_receiver(),
1428                &sample_swaps_hash(),
1429            )
1430            .unwrap();
1431        assert_eq!(h1, h2);
1432    }
1433
1434    #[test]
1435    fn client_fee_signing_hash_differs_by_chain_id() {
1436        let fee = sample_fee_params(100, sample_fee_receiver());
1437        let h1 = fee
1438            .eip712_signing_hash(
1439                1,
1440                &sample_router_address(),
1441                &sample_amount_in(),
1442                &sample_token_in(),
1443                &sample_token_out(),
1444                &sample_expected_amount_out(),
1445                &sample_min_amount_out(),
1446                &sample_swap_receiver(),
1447                &sample_swaps_hash(),
1448            )
1449            .unwrap();
1450        let h137 = fee
1451            .eip712_signing_hash(
1452                137,
1453                &sample_router_address(),
1454                &sample_amount_in(),
1455                &sample_token_in(),
1456                &sample_token_out(),
1457                &sample_expected_amount_out(),
1458                &sample_min_amount_out(),
1459                &sample_swap_receiver(),
1460                &sample_swaps_hash(),
1461            )
1462            .unwrap();
1463        assert_ne!(h1, h137);
1464    }
1465
1466    #[test]
1467    fn client_fee_signing_hash_differs_by_bps() {
1468        let h100 = sample_fee_params(100, sample_fee_receiver())
1469            .eip712_signing_hash(
1470                1,
1471                &sample_router_address(),
1472                &sample_amount_in(),
1473                &sample_token_in(),
1474                &sample_token_out(),
1475                &sample_expected_amount_out(),
1476                &sample_min_amount_out(),
1477                &sample_swap_receiver(),
1478                &sample_swaps_hash(),
1479            )
1480            .unwrap();
1481        let h200 = sample_fee_params(200, sample_fee_receiver())
1482            .eip712_signing_hash(
1483                1,
1484                &sample_router_address(),
1485                &sample_amount_in(),
1486                &sample_token_in(),
1487                &sample_token_out(),
1488                &sample_expected_amount_out(),
1489                &sample_min_amount_out(),
1490                &sample_swap_receiver(),
1491                &sample_swaps_hash(),
1492            )
1493            .unwrap();
1494        assert_ne!(h100, h200);
1495    }
1496
1497    #[test]
1498    fn client_fee_signing_hash_differs_by_expected_amount_out() {
1499        let fee = sample_fee_params(100, sample_fee_receiver());
1500        let quoted = fee
1501            .eip712_signing_hash(
1502                1,
1503                &sample_router_address(),
1504                &sample_amount_in(),
1505                &sample_token_in(),
1506                &sample_token_out(),
1507                &sample_expected_amount_out(),
1508                &sample_min_amount_out(),
1509                &sample_swap_receiver(),
1510                &sample_swaps_hash(),
1511            )
1512            .unwrap();
1513        let higher = fee
1514            .eip712_signing_hash(
1515                1,
1516                &sample_router_address(),
1517                &sample_amount_in(),
1518                &sample_token_in(),
1519                &sample_token_out(),
1520                &(sample_expected_amount_out() + BigUint::from(1u32)),
1521                &sample_min_amount_out(),
1522                &sample_swap_receiver(),
1523                &sample_swaps_hash(),
1524            )
1525            .unwrap();
1526        assert_ne!(quoted, higher);
1527    }
1528
1529    #[test]
1530    fn client_fee_signing_hash_differs_by_receiver() {
1531        let other_receiver = Bytes::copy_from_slice(&[0x55; 20]);
1532        let h1 = sample_fee_params(100, sample_fee_receiver())
1533            .eip712_signing_hash(
1534                1,
1535                &sample_router_address(),
1536                &sample_amount_in(),
1537                &sample_token_in(),
1538                &sample_token_out(),
1539                &sample_expected_amount_out(),
1540                &sample_min_amount_out(),
1541                &sample_swap_receiver(),
1542                &sample_swaps_hash(),
1543            )
1544            .unwrap();
1545        let h2 = sample_fee_params(100, other_receiver)
1546            .eip712_signing_hash(
1547                1,
1548                &sample_router_address(),
1549                &sample_amount_in(),
1550                &sample_token_in(),
1551                &sample_token_out(),
1552                &sample_expected_amount_out(),
1553                &sample_min_amount_out(),
1554                &sample_swap_receiver(),
1555                &sample_swaps_hash(),
1556            )
1557            .unwrap();
1558        assert_ne!(h1, h2);
1559    }
1560
1561    #[test]
1562    fn client_fee_signing_hash_rejects_bad_receiver_address() {
1563        let bad_addr = Bytes::copy_from_slice(&[0x44; 4]);
1564        let fee = sample_fee_params(100, bad_addr);
1565        assert!(matches!(
1566            fee.eip712_signing_hash(
1567                1,
1568                &sample_router_address(),
1569                &sample_amount_in(),
1570                &sample_token_in(),
1571                &sample_token_out(),
1572                &sample_expected_amount_out(),
1573                &sample_min_amount_out(),
1574                &sample_swap_receiver(),
1575                &sample_swaps_hash(),
1576            ),
1577            Err(crate::error::FyndError::Protocol(_))
1578        ));
1579    }
1580
1581    #[test]
1582    fn client_fee_signing_hash_rejects_bad_router_address() {
1583        let bad_addr = Bytes::copy_from_slice(&[0x33; 4]);
1584        let fee = sample_fee_params(100, sample_fee_receiver());
1585        assert!(matches!(
1586            fee.eip712_signing_hash(
1587                1,
1588                &bad_addr,
1589                &sample_amount_in(),
1590                &sample_token_in(),
1591                &sample_token_out(),
1592                &sample_expected_amount_out(),
1593                &sample_min_amount_out(),
1594                &sample_swap_receiver(),
1595                &sample_swaps_hash(),
1596            ),
1597            Err(crate::error::FyndError::Protocol(_))
1598        ));
1599    }
1600}