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