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