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