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